Add Turnstile protection to user actions
This commit is contained in:
@@ -39,6 +39,14 @@ CRON_SECRET=replace-with-a-long-random-secret
|
|||||||
# links inside emails. Set this to the real domain in production.
|
# links inside emails. Set this to the real domain in production.
|
||||||
NEXT_PUBLIC_SERVER_URL=http://localhost:3000
|
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
|
# Port the app container publishes on the host (behind a reverse proxy this
|
||||||
# usually doesn't need to be reachable directly - see DEPLOYMENT.md).
|
# usually doesn't need to be reachable directly - see DEPLOYMENT.md).
|
||||||
APP_PORT=3000
|
APP_PORT=3000
|
||||||
|
|||||||
@@ -190,6 +190,9 @@ See `.env.example` for the full, commented list. `setup.sh` writes the real file
|
|||||||
| `DATABASE_URI`, `POSTGRES_*` | Postgres connection |
|
| `DATABASE_URI`, `POSTGRES_*` | Postgres connection |
|
||||||
| `PAYLOAD_SECRET`, `MEETING_SESSION_SECRET`, `ANALYTICS_SALT_SECRET`, `CRON_SECRET` | Auto-generated, never overwritten afterwards |
|
| `PAYLOAD_SECRET`, `MEETING_SESSION_SECRET`, `ANALYTICS_SALT_SECRET`, `CRON_SECRET` | Auto-generated, never overwritten afterwards |
|
||||||
| `NEXT_PUBLIC_SERVER_URL` | Public URL of the site |
|
| `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 |
|
| `SMTP_*` | The existing ANOUMA mail system |
|
||||||
| `INBOUND_REPLY_TO`, `INBOUND_EMAIL_SECRET` | Mailbox and shared secret for the inbound email webhook (`/api/email/inbound`) |
|
| `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 |
|
| `LIVEKIT_URL`, `LIVEKIT_API_KEY`, `LIVEKIT_API_SECRET` | eigener LiveKit-SFU und serverseitige Token-Erzeugung |
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { resolveRedirect } from "@/lib/seo/redirects";
|
|||||||
import { siteConfig } from "@/lib/site";
|
import { siteConfig } from "@/lib/site";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
const turnstileSiteKey = process.env.TURNSTILE_SITE_KEY ?? "";
|
||||||
|
|
||||||
type Args = { params: Promise<{ slug: string }> };
|
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" };
|
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.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>}
|
{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>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
) : offer.bookable ? (
|
) : offer.bookable ? (
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { siteConfig } from "@/lib/site";
|
|||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
const FALLBACK_DESCRIPTION = "Ich freue mich, von dir zu hören.";
|
const FALLBACK_DESCRIPTION = "Ich freue mich, von dir zu hören.";
|
||||||
|
const turnstileSiteKey = process.env.TURNSTILE_SITE_KEY ?? "";
|
||||||
|
|
||||||
export async function generateMetadata(): Promise<Metadata> {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
const [contact, seoSettings] = await Promise.all([getContactGlobal(), getSEOSettingsGlobal()]);
|
const [contact, seoSettings] = await Promise.all([getContactGlobal(), getSEOSettingsGlobal()]);
|
||||||
@@ -60,7 +61,7 @@ export default async function KontaktPage({ searchParams }: { searchParams: Prom
|
|||||||
<Section tone="plain">
|
<Section tone="plain">
|
||||||
<div className="grid gap-14 lg:grid-cols-[1fr_0.8fr]">
|
<div className="grid gap-14 lg:grid-cols-[1fr_0.8fr]">
|
||||||
<Reveal>
|
<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>
|
||||||
|
|
||||||
<Reveal delay={0.1} className="space-y-8">
|
<Reveal delay={0.1} className="space-y-8">
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { LoginForm } from "@/components/auth/LoginForm";
|
|||||||
import { getCurrentCustomer } from "@/lib/auth/customer";
|
import { getCurrentCustomer } from "@/lib/auth/customer";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
const turnstileSiteKey = process.env.TURNSTILE_SITE_KEY ?? "";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Anmelden",
|
title: "Anmelden",
|
||||||
@@ -24,7 +25,7 @@ export default async function LoginPage() {
|
|||||||
<Section tone="plain">
|
<Section tone="plain">
|
||||||
<div className="mx-auto max-w-md">
|
<div className="mx-auto max-w-md">
|
||||||
<Suspense>
|
<Suspense>
|
||||||
<LoginForm />
|
<LoginForm siteKey={turnstileSiteKey} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
<p className="mt-6 text-center text-sm text-anouma-plum">
|
<p className="mt-6 text-center text-sm text-anouma-plum">
|
||||||
Noch kein Konto?{" "}
|
Noch kein Konto?{" "}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { RegisterForm } from "@/components/auth/RegisterForm";
|
|||||||
import { getCurrentCustomer } from "@/lib/auth/customer";
|
import { getCurrentCustomer } from "@/lib/auth/customer";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
const turnstileSiteKey = process.env.TURNSTILE_SITE_KEY ?? "";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Konto erstellen",
|
title: "Konto erstellen",
|
||||||
@@ -28,7 +29,7 @@ export default async function RegisterPage() {
|
|||||||
/>
|
/>
|
||||||
<Section tone="plain">
|
<Section tone="plain">
|
||||||
<div className="mx-auto max-w-md">
|
<div className="mx-auto max-w-md">
|
||||||
<RegisterForm />
|
<RegisterForm siteKey={turnstileSiteKey} />
|
||||||
<p className="mt-6 text-center text-sm text-anouma-plum">
|
<p className="mt-6 text-center text-sm text-anouma-plum">
|
||||||
Schon ein Konto?{" "}
|
Schon ein Konto?{" "}
|
||||||
<Link href="/login" className="font-medium text-anouma-mauve-dark underline underline-offset-4">
|
<Link href="/login" className="font-medium text-anouma-mauve-dark underline underline-offset-4">
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { siteConfig } from "@/lib/site";
|
|||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
const FALLBACK_DESCRIPTION = "Kennenlernen & Termine vereinbaren.";
|
const FALLBACK_DESCRIPTION = "Kennenlernen & Termine vereinbaren.";
|
||||||
|
const turnstileSiteKey = process.env.TURNSTILE_SITE_KEY ?? "";
|
||||||
|
|
||||||
export async function generateMetadata(): Promise<Metadata> {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
const [booking, seoSettings] = await Promise.all([getBookingGlobal(), getSEOSettingsGlobal()]);
|
const [booking, seoSettings] = await Promise.all([getBookingGlobal(), getSEOSettingsGlobal()]);
|
||||||
@@ -122,6 +123,7 @@ export default async function TerminBuchenPage() {
|
|||||||
/>
|
/>
|
||||||
<div className="mt-10">
|
<div className="mt-10">
|
||||||
<ContactForm
|
<ContactForm
|
||||||
|
siteKey={turnstileSiteKey}
|
||||||
subjectPrefix="Terminanfrage über anouma.org"
|
subjectPrefix="Terminanfrage über anouma.org"
|
||||||
type="appointment"
|
type="appointment"
|
||||||
submitLabel="Terminanfrage senden"
|
submitLabel="Terminanfrage senden"
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { combineDateAndTime } from "@/lib/meeting/status";
|
|||||||
import { formatBerlinDate, formatBerlinTime } from "@/lib/calendar/timezone";
|
import { formatBerlinDate, formatBerlinTime } from "@/lib/calendar/timezone";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
const turnstileSiteKey = process.env.TURNSTILE_SITE_KEY ?? "";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Meeting beitreten",
|
title: "Meeting beitreten",
|
||||||
@@ -42,7 +43,7 @@ export default async function BeitretenPage({ params }: Args) {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mt-10 rounded-[2rem] bg-white/70 p-8 text-left shadow-sm backdrop-blur">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { resolveRedirect } from "@/lib/seo/redirects";
|
|||||||
import { siteConfig } from "@/lib/site";
|
import { siteConfig } from "@/lib/site";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
const turnstileSiteKey = process.env.TURNSTILE_SITE_KEY ?? "";
|
||||||
|
|
||||||
type Args = { params: Promise<{ slug: string }> };
|
type Args = { params: Promise<{ slug: string }> };
|
||||||
|
|
||||||
@@ -137,7 +138,7 @@ export default async function EventDetailPage({ params }: Args) {
|
|||||||
<div>
|
<div>
|
||||||
<h2 className="font-serif text-2xl font-medium text-anouma-plum">Anmeldung</h2>
|
<h2 className="font-serif text-2xl font-medium text-anouma-plum">Anmeldung</h2>
|
||||||
<div className="mt-4 max-w-sm">
|
<div className="mt-4 max-w-sm">
|
||||||
<RegisterForm slug={slug} />
|
<RegisterForm slug={slug} siteKey={turnstileSiteKey} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import config from "@payload-config";
|
|||||||
import { getClientIp, checkRateLimit } from "@/lib/auth/rateLimit";
|
import { getClientIp, checkRateLimit } from "@/lib/auth/rateLimit";
|
||||||
import { newConversationAdminEmail } from "@/lib/email/conversationTemplates";
|
import { newConversationAdminEmail } from "@/lib/email/conversationTemplates";
|
||||||
import { sendEmail } from "@/lib/email/sendBookingEmails";
|
import { sendEmail } from "@/lib/email/sendBookingEmails";
|
||||||
|
import { verifyTurnstileToken } from "@/lib/turnstile/server";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@ type Body = {
|
|||||||
message?: unknown;
|
message?: unknown;
|
||||||
subject?: unknown;
|
subject?: unknown;
|
||||||
type?: unknown;
|
type?: unknown;
|
||||||
|
token?: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
function clean(value: unknown, max: number): string {
|
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)) {
|
if (!name || !email || !message || !/^\S+@\S+\.\S+$/.test(email)) {
|
||||||
return NextResponse.json({ error: "Bitte fülle alle Pflichtfelder korrekt aus." }, { status: 400 });
|
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 })) {
|
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 });
|
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"] }) });
|
||||||
|
}
|
||||||
@@ -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 { checkRateLimit, getClientIp } from "@/lib/auth/rateLimit";
|
||||||
import { groupRegistrationEmail } from "@/lib/email/conversationTemplates";
|
import { groupRegistrationEmail } from "@/lib/email/conversationTemplates";
|
||||||
import { sendEmail } from "@/lib/email/sendBookingEmails";
|
import { sendEmail } from "@/lib/email/sendBookingEmails";
|
||||||
|
import { verifyTurnstileToken } from "@/lib/turnstile/server";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ export async function POST(request: Request) {
|
|||||||
const phone = value(body.phone, 80);
|
const phone = value(body.phone, 80);
|
||||||
const message = value(body.message, 3000);
|
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 (!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 {
|
try {
|
||||||
const payload = await getPayload({ config });
|
const payload = await getPayload({ config });
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { authorizeMeeting } from "@/lib/meeting/access";
|
|||||||
import { checkRateLimit, getClientIp } from "@/lib/auth/rateLimit";
|
import { checkRateLimit, getClientIp } from "@/lib/auth/rateLimit";
|
||||||
import { createLiveKitToken, getLiveKitClientUrl } from "@/lib/meeting/livekit";
|
import { createLiveKitToken, getLiveKitClientUrl } from "@/lib/meeting/livekit";
|
||||||
import { isMeetingParticipantKicked } from "@/lib/meeting/kicks";
|
import { isMeetingParticipantKicked } from "@/lib/meeting/kicks";
|
||||||
|
import { verifyTurnstileToken } from "@/lib/turnstile/server";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
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 });
|
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 {
|
try {
|
||||||
body = await request.json();
|
body = await request.json();
|
||||||
if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error("Invalid body");
|
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) {
|
if (password.length > 200) {
|
||||||
return NextResponse.json({ error: "Das Meeting-Passwort ist ungültig." }, { status: 400 });
|
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 {
|
try {
|
||||||
const payload = await getPayload({ config });
|
const payload = await getPayload({ config });
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
|
|||||||
import { getPayload } from "payload";
|
import { getPayload } from "payload";
|
||||||
import config from "@payload-config";
|
import config from "@payload-config";
|
||||||
import { checkRateLimit, getClientIp } from "@/lib/auth/rateLimit";
|
import { checkRateLimit, getClientIp } from "@/lib/auth/rateLimit";
|
||||||
|
import { verifyTurnstileToken } from "@/lib/turnstile/server";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
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 });
|
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 {
|
try {
|
||||||
body = await request.json();
|
body = await request.json();
|
||||||
if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error("Invalid body");
|
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("@")) {
|
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 });
|
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 {
|
try {
|
||||||
const payload = await getPayload({ config });
|
const payload = await getPayload({ config });
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useState, type FormEvent } from "react";
|
import { useState, type FormEvent } from "react";
|
||||||
import { track } from "@/lib/analytics/client";
|
import { track } from "@/lib/analytics/client";
|
||||||
import { TextField, TextAreaField } from "@/components/FormField";
|
import { TextField, TextAreaField } from "@/components/FormField";
|
||||||
|
import { TurnstileWidget } from "@/components/TurnstileWidget";
|
||||||
|
|
||||||
type ContactFormProps = {
|
type ContactFormProps = {
|
||||||
toEmail?: string;
|
toEmail?: string;
|
||||||
@@ -12,12 +13,15 @@ type ContactFormProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function ContactForm({
|
export function ContactForm({
|
||||||
|
siteKey,
|
||||||
subjectPrefix = "Allgemeine Anfrage",
|
subjectPrefix = "Allgemeine Anfrage",
|
||||||
type = "contact",
|
type = "contact",
|
||||||
submitLabel = "Nachricht senden",
|
submitLabel = "Nachricht senden",
|
||||||
}: ContactFormProps) {
|
}: ContactFormProps & { siteKey: string }) {
|
||||||
const [state, setState] = useState<{ sent: boolean; error?: string }>({ sent: false });
|
const [state, setState] = useState<{ sent: boolean; error?: string }>({ sent: false });
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [token, setToken] = useState<string | null>(null);
|
||||||
|
const [resetKey, setResetKey] = useState(0);
|
||||||
|
|
||||||
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
|
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -31,7 +35,7 @@ export function ContactForm({
|
|||||||
const response = await fetch("/api/contact/inquiry", {
|
const response = await fetch("/api/contact/inquiry", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
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(() => ({}));
|
const result = await response.json().catch(() => ({}));
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -46,6 +50,7 @@ export function ContactForm({
|
|||||||
setState({ sent: false, error: "Verbindung fehlgeschlagen. Bitte versuche es erneut." });
|
setState({ sent: false, error: "Verbindung fehlgeschlagen. Bitte versuche es erneut." });
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
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="Name" id="name" name="name" type="text" required autoComplete="name" />
|
||||||
<TextField label="E-Mail" id="email" name="email" type="email" required autoComplete="email" />
|
<TextField label="E-Mail" id="email" name="email" type="email" required autoComplete="email" />
|
||||||
<TextAreaField label="Deine Nachricht" id="message" name="message" required rows={5} />
|
<TextAreaField label="Deine Nachricht" id="message" name="message" required rows={5} />
|
||||||
|
<TurnstileWidget siteKey={siteKey} action="contact" resetKey={resetKey} onTokenChange={setToken} />
|
||||||
<button
|
<button
|
||||||
type="submit"
|
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"
|
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}
|
{submitting ? "Wird gesendet …" : submitLabel}
|
||||||
|
|||||||
@@ -2,10 +2,13 @@
|
|||||||
|
|
||||||
import { useState, type FormEvent } from "react";
|
import { useState, type FormEvent } from "react";
|
||||||
import { TextField, TextAreaField } from "@/components/FormField";
|
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 [state, setState] = useState<{ sent: boolean; error?: string }>({ sent: false });
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [token, setToken] = useState<string | null>(null);
|
||||||
|
const [resetKey, setResetKey] = useState(0);
|
||||||
|
|
||||||
async function submit(event: FormEvent<HTMLFormElement>) {
|
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -22,13 +25,14 @@ export function GroupRegistrationForm({ offerSlug }: { offerSlug: string }) {
|
|||||||
email: data.get("email"),
|
email: data.get("email"),
|
||||||
phone: data.get("phone"),
|
phone: data.get("phone"),
|
||||||
message: data.get("message"),
|
message: data.get("message"),
|
||||||
|
token,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const result = await response.json().catch(() => ({}));
|
const result = await response.json().catch(() => ({}));
|
||||||
if (!response.ok) setState({ sent: false, error: result.error || "Die Anmeldung konnte nicht gesendet werden." });
|
if (!response.ok) setState({ sent: false, error: result.error || "Die Anmeldung konnte nicht gesendet werden." });
|
||||||
else { setState({ sent: true }); event.currentTarget.reset(); }
|
else { setState({ sent: true }); event.currentTarget.reset(); }
|
||||||
} catch { setState({ sent: false, error: "Verbindung fehlgeschlagen. Bitte versuche es erneut." }); }
|
} 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>;
|
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="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" />
|
<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} />
|
<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>}
|
{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>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,12 +5,15 @@ import { useRouter, useSearchParams } from "next/navigation";
|
|||||||
import { ResendVerificationButton } from "./ResendVerificationButton";
|
import { ResendVerificationButton } from "./ResendVerificationButton";
|
||||||
import { TextField, FormError } from "@/components/FormField";
|
import { TextField, FormError } from "@/components/FormField";
|
||||||
import { safeLoginRedirect } from "@/lib/auth/redirect";
|
import { safeLoginRedirect } from "@/lib/auth/redirect";
|
||||||
|
import { TurnstileWidget } from "@/components/TurnstileWidget";
|
||||||
|
|
||||||
export function LoginForm() {
|
export function LoginForm({ siteKey }: { siteKey: string }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [token, setToken] = useState<string | null>(null);
|
||||||
|
const [resetKey, setResetKey] = useState(0);
|
||||||
|
|
||||||
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
|
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -26,10 +29,11 @@ export function LoginForm() {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
body: JSON.stringify({ email, password }),
|
body: JSON.stringify({ email, password, token }),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
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);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -38,6 +42,8 @@ export function LoginForm() {
|
|||||||
} catch {
|
} catch {
|
||||||
setError("Verbindung fehlgeschlagen. Bitte versuche es erneut.");
|
setError("Verbindung fehlgeschlagen. Bitte versuche es erneut.");
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
} finally {
|
||||||
|
setResetKey((key) => key + 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,10 +51,14 @@ export function LoginForm() {
|
|||||||
<form onSubmit={handleSubmit} className="space-y-5">
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
<TextField label="E-Mail" id="email" name="email" type="email" required autoComplete="email" />
|
<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" />
|
<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>}
|
{error && <FormError>{error}</FormError>}
|
||||||
<button
|
<button
|
||||||
type="submit"
|
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"
|
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"}
|
{loading ? "Wird geprüft …" : "Anmelden"}
|
||||||
|
|||||||
@@ -3,11 +3,14 @@
|
|||||||
import { useState, type FormEvent } from "react";
|
import { useState, type FormEvent } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { TextField, FormError } from "@/components/FormField";
|
import { TextField, FormError } from "@/components/FormField";
|
||||||
|
import { TurnstileWidget } from "@/components/TurnstileWidget";
|
||||||
|
|
||||||
export function RegisterForm() {
|
export function RegisterForm({ siteKey }: { siteKey: string }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [token, setToken] = useState<string | null>(null);
|
||||||
|
const [resetKey, setResetKey] = useState(0);
|
||||||
|
|
||||||
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
|
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -23,30 +26,22 @@ export function RegisterForm() {
|
|||||||
const createRes = await fetch("/api/customers", {
|
const createRes = await fetch("/api/customers", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ name, email, password }),
|
body: JSON.stringify({ name, email, password, token }),
|
||||||
});
|
});
|
||||||
if (!createRes.ok) {
|
if (!createRes.ok) {
|
||||||
const body = await createRes.json().catch(() => null);
|
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);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const loginRes = await fetch("/api/customers/login", {
|
// Login is separately protected by its own single-use Turnstile token.
|
||||||
method: "POST",
|
router.push("/login?registered=1");
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
credentials: "include",
|
|
||||||
body: JSON.stringify({ email, password }),
|
|
||||||
});
|
|
||||||
if (!loginRes.ok) {
|
|
||||||
router.push("/login");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
router.push("/konto");
|
|
||||||
router.refresh();
|
|
||||||
} catch {
|
} catch {
|
||||||
setError("Verbindung fehlgeschlagen. Bitte versuche es erneut.");
|
setError("Verbindung fehlgeschlagen. Bitte versuche es erneut.");
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
} finally {
|
||||||
|
setResetKey((key) => key + 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,10 +58,11 @@ export function RegisterForm() {
|
|||||||
minLength={8}
|
minLength={8}
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
/>
|
/>
|
||||||
|
<TurnstileWidget siteKey={siteKey} action="registration" resetKey={resetKey} onTokenChange={setToken} />
|
||||||
{error && <FormError>{error}</FormError>}
|
{error && <FormError>{error}</FormError>}
|
||||||
<button
|
<button
|
||||||
type="submit"
|
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"
|
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"}
|
{loading ? "Wird erstellt …" : "Konto erstellen"}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useState, type FormEvent } from "react";
|
import { useState, type FormEvent } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { fieldClass, TextField, FormError } from "@/components/FormField";
|
import { fieldClass, TextField, FormError } from "@/components/FormField";
|
||||||
|
import { TurnstileWidget } from "@/components/TurnstileWidget";
|
||||||
|
|
||||||
export const MEETING_SESSION_KEY = "anouma-meeting-session";
|
export const MEETING_SESSION_KEY = "anouma-meeting-session";
|
||||||
const MEETING_CLIENT_ID_KEY = "anouma-meeting-client-id";
|
const MEETING_CLIENT_ID_KEY = "anouma-meeting-client-id";
|
||||||
@@ -37,13 +38,15 @@ export type StoredMeetingSession = {
|
|||||||
eventSlug: string;
|
eventSlug: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function JoinForm({ slug }: { slug: string }) {
|
export function JoinForm({ slug, siteKey }: { slug: string; siteKey: string }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [token, setToken] = useState<string | null>(null);
|
||||||
|
const [resetKey, setResetKey] = useState(0);
|
||||||
|
|
||||||
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
|
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -53,7 +56,7 @@ export function JoinForm({ slug }: { slug: string }) {
|
|||||||
const res = await fetch(`/api/meetings/${slug}/join`, {
|
const res = await fetch(`/api/meetings/${slug}/join`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ name, password, clientId: getOrCreateClientId() }),
|
body: JSON.stringify({ name, password, clientId: getOrCreateClientId(), token }),
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
@@ -78,6 +81,8 @@ export function JoinForm({ slug }: { slug: string }) {
|
|||||||
} catch {
|
} catch {
|
||||||
setError("Verbindung fehlgeschlagen. Bitte versuche es erneut.");
|
setError("Verbindung fehlgeschlagen. Bitte versuche es erneut.");
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
} finally {
|
||||||
|
setResetKey((key) => key + 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,10 +123,11 @@ export function JoinForm({ slug }: { slug: string }) {
|
|||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<TurnstileWidget siteKey={siteKey} action="meeting-join" resetKey={resetKey} onTokenChange={setToken} />
|
||||||
{error && <FormError>{error}</FormError>}
|
{error && <FormError>{error}</FormError>}
|
||||||
<button
|
<button
|
||||||
type="submit"
|
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"
|
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"}
|
{loading ? "Wird geprüft …" : "Meeting betreten"}
|
||||||
|
|||||||
@@ -2,12 +2,15 @@
|
|||||||
|
|
||||||
import { useState, type FormEvent } from "react";
|
import { useState, type FormEvent } from "react";
|
||||||
import { TextField, FormError } from "@/components/FormField";
|
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 [name, setName] = useState("");
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [status, setStatus] = useState<"idle" | "loading" | "done" | "error">("idle");
|
const [status, setStatus] = useState<"idle" | "loading" | "done" | "error">("idle");
|
||||||
const [error, setError] = useState<string | null>(null);
|
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>) {
|
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -17,7 +20,7 @@ export function RegisterForm({ slug }: { slug: string }) {
|
|||||||
const res = await fetch(`/api/meetings/${slug}/register`, {
|
const res = await fetch(`/api/meetings/${slug}/register`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ name, email }),
|
body: JSON.stringify({ name, email, token }),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -29,6 +32,8 @@ export function RegisterForm({ slug }: { slug: string }) {
|
|||||||
} catch {
|
} catch {
|
||||||
setError("Verbindung fehlgeschlagen. Bitte versuche es erneut.");
|
setError("Verbindung fehlgeschlagen. Bitte versuche es erneut.");
|
||||||
setStatus("error");
|
setStatus("error");
|
||||||
|
} finally {
|
||||||
|
setResetKey((key) => key + 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,10 +65,11 @@ export function RegisterForm({ slug }: { slug: string }) {
|
|||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
/>
|
/>
|
||||||
|
<TurnstileWidget siteKey={siteKey} action="meeting-registration" resetKey={resetKey} onTokenChange={setToken} />
|
||||||
{error && <FormError>{error}</FormError>}
|
{error && <FormError>{error}</FormError>}
|
||||||
<button
|
<button
|
||||||
type="submit"
|
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"
|
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"}
|
{status === "loading" ? "Wird gesendet …" : "Zum Termin anmelden"}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -304,6 +304,9 @@ MEETING_SESSION_SECRET=
|
|||||||
ANALYTICS_SALT_SECRET=
|
ANALYTICS_SALT_SECRET=
|
||||||
CRON_SECRET=
|
CRON_SECRET=
|
||||||
NEXT_PUBLIC_SERVER_URL=
|
NEXT_PUBLIC_SERVER_URL=
|
||||||
|
TURNSTILE_SITE_KEY=0x4AAAAAAE7AqgHiQF9DffQO
|
||||||
|
TURNSTILE_SECRET_KEY=
|
||||||
|
TURNSTILE_HOSTNAMES=anouma.org
|
||||||
APP_PORT=3000
|
APP_PORT=3000
|
||||||
SMTP_HOST=
|
SMTP_HOST=
|
||||||
SMTP_PORT=587
|
SMTP_PORT=587
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user