feat(security): Turnstile schuetzt jetzt alle oeffentlichen Eingabeformulare
Bisher waren nur Registrierung, Newsletter und Firmenanfrage mit einem CAPTCHA versehen. Damit standen ausgerechnet die Formulare ungeschuetzt offen, die am meisten hergeben: - Login und Zwei-Faktor-Eingabe — Durchprobieren erbeuteter Zugangsdaten bzw. des sechsstelligen Codes. - Passwort vergessen/zuruecksetzen — Mailversand an fremde Adressen und ein Weg herauszufinden, welche Adressen registriert sind. - Checkout — automatisierte Bestellungen blockieren Lagerbestand und eroeffnen Stripe-Sitzungen, was die Zahlungsstrecke fuer Kartentests brauchbar macht. - Verfuegbarkeits-Benachrichtigung — nimmt eine fremde E-Mail-Adresse entgegen und loest spaeter Versand an sie aus. Die Pruefung sitzt jeweils VOR jeder weiteren Arbeit, damit automatisierte Versuche Datenbank, Mailversand und Ratenlimit gar nicht erst belasten. Der Checkout bekommt das Token ueber den JSON-Body statt ueber ein Formularfeld — er schickt kein Formular ab. Neu ist isTurnstileValid(formData, ip) als gemeinsame Stelle: haette eine Seite den Feldnamen anders geschrieben, waere das Token leer angekommen — und weil ein leeres Token bei nicht konfiguriertem Secret durchgeht, waere der Schutz dort lautlos ausgefallen. Genau deshalb ist die Turnstile-Pruefung in "pnpm preflight" jetzt ein BLOCKER statt einer Warnung: fehlt der Schluessel, schlaegt die Pruefung nicht fehl, sondern wird still uebersprungen (lib/turnstile.ts). Ohne diesen Blocker koennte der Shop mit dem Eindruck live gehen, geschuetzt zu sein, waehrend jedes Formular offensteht. Bewusst NICHT geschuetzt ist die Sendungsverfolgung: sie ist ein GET-Formular, dessen Ergebnis verlinkbar und neu ladbar bleiben soll — ein Token in der Adresszeile waere nach dem ersten Aufruf verbraucht. Sie bleibt rate-limitiert und verlangt Bestellnummer UND zugehoerige E-Mail-Adresse. Angemeldete Bereiche (Adressen, Bewertungen, Wunschliste, Retouren) bleiben ebenfalls ohne CAPTCHA: dort ist die Anmeldung die Huerde, ein zusaetzliches Raetsel kostete nur Bedienbarkeit. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
1 parent
6135c1f9c0
commit
33580b3290
14 files changed
+118
-4
No files matched your search
@@ -183,7 +183,20 @@ Funktioniert mit jedem SMTP-Anbieter, z.B. dem E-Mail-Postfach eures Domain-Host
|
|||||||
|
|
||||||
## CAPTCHA (Cloudflare Turnstile)
|
## CAPTCHA (Cloudflare Turnstile)
|
||||||
|
|
||||||
Registrierung, Newsletter-Anmeldung und Firmenanfrage sind optional durch [Cloudflare Turnstile](https://developers.cloudflare.com/turnstile/) gegen automatisierten Missbrauch geschützt — zusätzlich zum bestehenden Rate-Limiting. Ohne konfigurierte Keys wird kein Widget angezeigt und die serverseitige Prüfung übersprungen, die Formulare bleiben also auch ohne Setup voll nutzbar:
|
**Alle öffentlichen Eingabeformulare** sind durch [Cloudflare Turnstile](https://developers.cloudflare.com/turnstile/) gegen automatisierten Missbrauch geschützt — zusätzlich zum bestehenden Rate-Limiting:
|
||||||
|
|
||||||
|
| Formular | Missbrauch, der damit abgewehrt wird |
|
||||||
|
|----------|--------------------------------------|
|
||||||
|
| Login | Durchprobieren erbeuteter Zugangsdaten |
|
||||||
|
| Zwei-Faktor-Code | Durchprobieren des sechsstelligen Codes |
|
||||||
|
| Passwort vergessen / zurücksetzen | Mailversand an fremde Adressen, Abfragen welche Adressen registriert sind |
|
||||||
|
| Registrierung | Massenhaft angelegte Konten |
|
||||||
|
| Checkout | Blockieren von Lagerbestand, Kartentests über die Zahlungsstrecke |
|
||||||
|
| Newsletter | Eintragen fremder Adressen |
|
||||||
|
| Firmenanfrage | Spam über das Kontaktformular |
|
||||||
|
| Verfügbarkeits-Benachrichtigung | Mailversand an fremde Adressen |
|
||||||
|
|
||||||
|
Nicht geschützt ist die Sendungsverfolgung: sie ist ein GET-Formular, dessen Ergebnis verlinkbar und neu ladbar bleiben soll — ein Token in der Adresszeile wäre nach dem ersten Aufruf verbraucht. Sie ist stattdessen rate-limitiert und verlangt Bestellnummer **und** zugehörige E-Mail-Adresse.
|
||||||
|
|
||||||
1. Im [Cloudflare-Dashboard](https://dash.cloudflare.com/?to=/:account/turnstile) einen neuen Turnstile-Widget-Typ „Managed" anlegen (kostenlos, keine Kreditkarte nötig).
|
1. Im [Cloudflare-Dashboard](https://dash.cloudflare.com/?to=/:account/turnstile) einen neuen Turnstile-Widget-Typ „Managed" anlegen (kostenlos, keine Kreditkarte nötig).
|
||||||
2. Den **Site Key** als `NEXT_PUBLIC_TURNSTILE_SITE_KEY` und den **Secret Key** als `TURNSTILE_SECRET_KEY` in `.env` eintragen.
|
2. Den **Site Key** als `NEXT_PUBLIC_TURNSTILE_SITE_KEY` und den **Secret Key** als `TURNSTILE_SECRET_KEY` in `.env` eintragen.
|
||||||
|
|||||||
@@ -149,10 +149,10 @@ must(
|
|||||||
must("DATABASE_URL gesetzt", isSet("DATABASE_URL"), "Ohne Datenbank startet die Anwendung nicht.");
|
must("DATABASE_URL gesetzt", isSet("DATABASE_URL"), "Ohne Datenbank startet die Anwendung nicht.");
|
||||||
|
|
||||||
// --- Missbrauchsschutz -----------------------------------------------------
|
// --- Missbrauchsschutz -----------------------------------------------------
|
||||||
should(
|
must(
|
||||||
"Cloudflare Turnstile konfiguriert",
|
"Cloudflare Turnstile konfiguriert",
|
||||||
isSet("TURNSTILE_SECRET_KEY") && isSet("NEXT_PUBLIC_TURNSTILE_SITE_KEY"),
|
isSet("TURNSTILE_SECRET_KEY") && isSet("NEXT_PUBLIC_TURNSTILE_SITE_KEY"),
|
||||||
"Ohne Turnstile laufen Registrierung, Newsletter und Firmenanfrage ohne CAPTCHA — bei einem oeffentlichen Shop sammeln sich darueber schnell Bot-Eintraege."
|
"Blocker, weil die Pruefung bei fehlendem Schluessel nicht etwa fehlschlaegt, sondern STILL UEBERSPRUNGEN wird (src/lib/turnstile.ts) — alle oeffentlichen Formulare stuenden dann ungeschuetzt offen, ohne dass es auffiele: Login und Zwei-Faktor-Eingabe (Ausprobieren von Zugangsdaten), Passwort-Reset (Mailversand an fremde Adressen), Registrierung, Checkout (Bestandsblockade, Kartentests), Newsletter, Firmenanfrage und Verfuegbarkeits-Benachrichtigung. Beide Werte gehoeren zusammen: der Site-Key ist oeffentlich, der Secret-Key gehoert ins Secret."
|
||||||
);
|
);
|
||||||
|
|
||||||
// --- Inhalte, die rechtlich verpflichtend sind -----------------------------
|
// --- Inhalte, die rechtlich verpflichtend sind -----------------------------
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { getProductsByIds } from "@/lib/data";
|
|||||||
import { getCurrentCustomer } from "@/lib/auth";
|
import { getCurrentCustomer } from "@/lib/auth";
|
||||||
import { rateLimit, getClientIp } from "@/lib/rate-limit";
|
import { rateLimit, getClientIp } from "@/lib/rate-limit";
|
||||||
import { isTrustedOrigin } from "@/lib/csrf";
|
import { isTrustedOrigin } from "@/lib/csrf";
|
||||||
|
import { verifyTurnstileToken } from "@/lib/turnstile";
|
||||||
import { getShippingCostCents } from "@/lib/shipping";
|
import { getShippingCostCents } from "@/lib/shipping";
|
||||||
import { isDiscountCodeValid, calculateDiscountCents } from "@/lib/discount-calc";
|
import { isDiscountCodeValid, calculateDiscountCents } from "@/lib/discount-calc";
|
||||||
import { ensureBulkDiscountCode } from "@/lib/bulk-discount";
|
import { ensureBulkDiscountCode } from "@/lib/bulk-discount";
|
||||||
@@ -55,6 +56,23 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json().catch(() => null);
|
const body = await request.json().catch(() => null);
|
||||||
|
|
||||||
|
// CAPTCHA vor allem Weiteren: eine Bestellung legt Datensaetze an,
|
||||||
|
// reserviert Lagerbestand und eroeffnet eine Stripe-Sitzung. Automatisiert
|
||||||
|
// abgeschickt liesse sich damit der Bestand blockieren und die
|
||||||
|
// Zahlungsstrecke fuer Kartentests missbrauchen. Das Token kommt hier aus
|
||||||
|
// dem JSON-Body, weil der Checkout kein Formular abschickt.
|
||||||
|
const turnstileToken =
|
||||||
|
body && typeof body === "object" && "turnstileToken" in body
|
||||||
|
? String((body as { turnstileToken?: unknown }).turnstileToken ?? "")
|
||||||
|
: "";
|
||||||
|
if (!(await verifyTurnstileToken(turnstileToken, ip))) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Die Sicherheitsprüfung ist fehlgeschlagen. Bitte lade die Seite neu und versuche es erneut." },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const parsed = checkoutSchema.safeParse(body);
|
const parsed = checkoutSchema.safeParse(body);
|
||||||
|
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { formatPrice } from "@/lib/format";
|
|||||||
import { resolveShippingCostCents, type ShippingTierLike } from "@/lib/shipping-calc";
|
import { resolveShippingCostCents, type ShippingTierLike } from "@/lib/shipping-calc";
|
||||||
import { resolveBulkDiscount, type BulkDiscountTierLike } from "@/lib/bulk-discount-calc";
|
import { resolveBulkDiscount, type BulkDiscountTierLike } from "@/lib/bulk-discount-calc";
|
||||||
import { useI18n } from "@/i18n/i18n-provider";
|
import { useI18n } from "@/i18n/i18n-provider";
|
||||||
|
import { TurnstileWidget } from "@/components/turnstile-widget";
|
||||||
|
|
||||||
type Fulfillment = "VERSAND" | "ABHOLUNG";
|
type Fulfillment = "VERSAND" | "ABHOLUNG";
|
||||||
|
|
||||||
@@ -41,6 +42,7 @@ export function CheckoutForm({
|
|||||||
tiers,
|
tiers,
|
||||||
bulkDiscountTiers,
|
bulkDiscountTiers,
|
||||||
packstationSearchAvailable = false,
|
packstationSearchAvailable = false,
|
||||||
|
turnstileSiteKey,
|
||||||
}: {
|
}: {
|
||||||
initialValues?: CheckoutInitialValues;
|
initialValues?: CheckoutInitialValues;
|
||||||
tiers: ShippingTierLike[];
|
tiers: ShippingTierLike[];
|
||||||
@@ -51,6 +53,8 @@ export function CheckoutForm({
|
|||||||
* statt Kundschaft in eine Suche laufen zu lassen, die immer fehlschlaegt.
|
* statt Kundschaft in eine Suche laufen zu lassen, die immer fehlschlaegt.
|
||||||
*/
|
*/
|
||||||
packstationSearchAvailable?: boolean;
|
packstationSearchAvailable?: boolean;
|
||||||
|
/** Leer, wenn Turnstile nicht konfiguriert ist — dann entfaellt das Widget. */
|
||||||
|
turnstileSiteKey?: string;
|
||||||
}) {
|
}) {
|
||||||
const { t, locale } = useI18n();
|
const { t, locale } = useI18n();
|
||||||
const items = useCartStore((s) => s.items);
|
const items = useCartStore((s) => s.items);
|
||||||
@@ -59,6 +63,9 @@ export function CheckoutForm({
|
|||||||
const totalQuantity = cartItemCount(items);
|
const totalQuantity = cartItemCount(items);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
// Der Checkout schickt JSON statt eines Formulars, deshalb das Widget mit
|
||||||
|
// Token-Rueckgabe statt eines versteckten Feldes.
|
||||||
|
const [turnstileToken, setTurnstileToken] = useState<string | null>(null);
|
||||||
const [fulfillment, setFulfillment] = useState<Fulfillment>("VERSAND");
|
const [fulfillment, setFulfillment] = useState<Fulfillment>("VERSAND");
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -160,6 +167,7 @@ export function CheckoutForm({
|
|||||||
items: items.map((item) => ({ productId: item.productId, quantity: item.quantity })),
|
items: items.map((item) => ({ productId: item.productId, quantity: item.quantity })),
|
||||||
discountCode: String(formData.get("discountCode") ?? "").trim() || undefined,
|
discountCode: String(formData.get("discountCode") ?? "").trim() || undefined,
|
||||||
giftMessage: isGift ? String(formData.get("giftMessage") ?? "").trim() || undefined : undefined,
|
giftMessage: isGift ? String(formData.get("giftMessage") ?? "").trim() || undefined : undefined,
|
||||||
|
turnstileToken: turnstileToken ?? undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -456,6 +464,11 @@ export function CheckoutForm({
|
|||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{turnstileSiteKey ? (
|
||||||
|
<div className="mb-4">
|
||||||
|
<TurnstileWidget siteKey={turnstileSiteKey} onVerify={setTurnstileToken} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<Button type="submit" disabled={submitting} className="w-full sm:w-auto">
|
<Button type="submit" disabled={submitting} className="w-full sm:w-auto">
|
||||||
{submitting ? t.checkoutPage.submitting : t.checkoutPage.placeOrder}
|
{submitting ? t.checkoutPage.submitting : t.checkoutPage.placeOrder}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export default async function CheckoutPage() {
|
|||||||
|
|
||||||
const customer = await getCurrentCustomer();
|
const customer = await getCurrentCustomer();
|
||||||
if (!customer) return <CheckoutForm
|
if (!customer) return <CheckoutForm
|
||||||
|
turnstileSiteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY}
|
||||||
tiers={tierValues}
|
tiers={tierValues}
|
||||||
bulkDiscountTiers={bulkDiscountTiers}
|
bulkDiscountTiers={bulkDiscountTiers}
|
||||||
packstationSearchAvailable={isPackstationSearchConfigured()}
|
packstationSearchAvailable={isPackstationSearchConfigured()}
|
||||||
@@ -51,6 +52,7 @@ export default async function CheckoutPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return <CheckoutForm
|
return <CheckoutForm
|
||||||
|
turnstileSiteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY}
|
||||||
initialValues={initialValues}
|
initialValues={initialValues}
|
||||||
tiers={tierValues}
|
tiers={tierValues}
|
||||||
bulkDiscountTiers={bulkDiscountTiers}
|
bulkDiscountTiers={bulkDiscountTiers}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { isProductWishlisted } from "@/lib/wishlist";
|
|||||||
import { getShippingTiers } from "@/lib/shipping";
|
import { getShippingTiers } from "@/lib/shipping";
|
||||||
import { resolveShippingCostCents } from "@/lib/shipping-calc";
|
import { resolveShippingCostCents } from "@/lib/shipping-calc";
|
||||||
import { submitReviewAction, toggleWishlistAction, subscribeStockNotificationAction } from "../actions";
|
import { submitReviewAction, toggleWishlistAction, subscribeStockNotificationAction } from "../actions";
|
||||||
|
import { TurnstileField } from "@/components/turnstile-widget";
|
||||||
import { getLocale } from "@/i18n/locale";
|
import { getLocale } from "@/i18n/locale";
|
||||||
import { getDictionary } from "@/i18n/get-dictionary";
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
import type { WissenSlug } from "@/i18n/dictionary-type";
|
import type { WissenSlug } from "@/i18n/dictionary-type";
|
||||||
@@ -257,6 +258,11 @@ export default async function ProductDetailPage(
|
|||||||
{t.productPage.notifySubmit}
|
{t.productPage.notifySubmit}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY ? (
|
||||||
|
<div className="mt-2">
|
||||||
|
<TurnstileField siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</form>
|
</form>
|
||||||
{notifyError ? (
|
{notifyError ? (
|
||||||
<p className="mt-2 text-xs text-red-700">{notifyError}</p>
|
<p className="mt-2 text-xs text-red-700">{notifyError}</p>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { hasCustomerPurchased, getCustomerReview } from "@/lib/reviews";
|
|||||||
import { isProductWishlisted } from "@/lib/wishlist";
|
import { isProductWishlisted } from "@/lib/wishlist";
|
||||||
import { reviewSchema, stockNotificationSchema } from "@/lib/validation";
|
import { reviewSchema, stockNotificationSchema } from "@/lib/validation";
|
||||||
import { rateLimit, getClientIp } from "@/lib/rate-limit";
|
import { rateLimit, getClientIp } from "@/lib/rate-limit";
|
||||||
|
import { isTurnstileValid } from "@/lib/turnstile";
|
||||||
import { getLocale } from "@/i18n/locale";
|
import { getLocale } from "@/i18n/locale";
|
||||||
|
|
||||||
const REVIEW_ATTEMPTS_PER_WINDOW = 10;
|
const REVIEW_ATTEMPTS_PER_WINDOW = 10;
|
||||||
@@ -119,6 +120,13 @@ export async function subscribeStockNotificationAction(formData: FormData): Prom
|
|||||||
failNotify("/shop", "Zu viele Anfragen. Bitte versuche es später erneut.");
|
failNotify("/shop", "Zu viele Anfragen. Bitte versuche es später erneut.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Das Formular nimmt eine fremde E-Mail-Adresse entgegen und loest spaeter
|
||||||
|
// Mailversand an sie aus — ohne CAPTCHA ein bequemer Weg, Dritte ueber
|
||||||
|
// unseren Absender anzuschreiben.
|
||||||
|
if (!(await isTurnstileValid(formData, ip))) {
|
||||||
|
failNotify("/shop", "Die Sicherheitsprüfung ist fehlgeschlagen. Bitte versuche es erneut.");
|
||||||
|
}
|
||||||
|
|
||||||
const parsed = stockNotificationSchema.safeParse({
|
const parsed = stockNotificationSchema.safeParse({
|
||||||
productId: formData.get("productId"),
|
productId: formData.get("productId"),
|
||||||
email: formData.get("email"),
|
email: formData.get("email"),
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { loginSchema } from "@/lib/validation";
|
|||||||
import { rateLimit, getClientIp } from "@/lib/rate-limit";
|
import { rateLimit, getClientIp } from "@/lib/rate-limit";
|
||||||
import type { Customer } from "@/generated/prisma/client";
|
import type { Customer } from "@/generated/prisma/client";
|
||||||
import { fail } from "./_shared";
|
import { fail } from "./_shared";
|
||||||
|
import { isTurnstileValid } from "@/lib/turnstile";
|
||||||
|
|
||||||
const LOGIN_ATTEMPTS_PER_WINDOW = 10;
|
const LOGIN_ATTEMPTS_PER_WINDOW = 10;
|
||||||
const LOGIN_WINDOW_MS = 15 * 60 * 1000;
|
const LOGIN_WINDOW_MS = 15 * 60 * 1000;
|
||||||
@@ -39,6 +40,12 @@ export async function loginAction(formData: FormData): Promise<void> {
|
|||||||
fail("/konto", "Zu viele Anmeldeversuche. Bitte versuche es in 15 Minuten erneut.");
|
fail("/konto", "Zu viele Anmeldeversuche. Bitte versuche es in 15 Minuten erneut.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CAPTCHA vor jeder weiteren Arbeit: haelt automatisierte Versuche ab,
|
||||||
|
// bevor sie Datenbank, Mailversand oder das Ratenlimit belasten.
|
||||||
|
if (!(await isTurnstileValid(formData, ip))) {
|
||||||
|
fail("/konto", "Die Sicherheitsprüfung ist fehlgeschlagen. Bitte versuche es erneut.");
|
||||||
|
}
|
||||||
|
|
||||||
const parsed = loginSchema.safeParse({
|
const parsed = loginSchema.safeParse({
|
||||||
email: formData.get("email"),
|
email: formData.get("email"),
|
||||||
password: formData.get("password"),
|
password: formData.get("password"),
|
||||||
@@ -103,6 +110,12 @@ export async function verifyTwoFactorAction(formData: FormData): Promise<void> {
|
|||||||
fail("/konto/zwei-faktor-code", "Zu viele Versuche. Bitte versuche es später erneut.");
|
fail("/konto/zwei-faktor-code", "Zu viele Versuche. Bitte versuche es später erneut.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CAPTCHA vor jeder weiteren Arbeit: haelt automatisierte Versuche ab,
|
||||||
|
// bevor sie Datenbank, Mailversand oder das Ratenlimit belasten.
|
||||||
|
if (!(await isTurnstileValid(formData, ip))) {
|
||||||
|
fail("/konto/zwei-faktor-code", "Die Sicherheitsprüfung ist fehlgeschlagen. Bitte versuche es erneut.");
|
||||||
|
}
|
||||||
|
|
||||||
const code = String(formData.get("code") ?? "").trim();
|
const code = String(formData.get("code") ?? "").trim();
|
||||||
const customer = await prisma.customer.findUnique({ where: { id: customerId } });
|
const customer = await prisma.customer.findUnique({ where: { id: customerId } });
|
||||||
if (!customer || !customer.twoFactorEnabled || !customer.twoFactorSecret) {
|
if (!customer || !customer.twoFactorEnabled || !customer.twoFactorSecret) {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { sendMail } from "@/lib/mail";
|
|||||||
import { securityAlertEmail } from "@/lib/email-templates";
|
import { securityAlertEmail } from "@/lib/email-templates";
|
||||||
import { getLocale } from "@/i18n/locale";
|
import { getLocale } from "@/i18n/locale";
|
||||||
import { fail } from "./_shared";
|
import { fail } from "./_shared";
|
||||||
|
import { isTurnstileValid } from "@/lib/turnstile";
|
||||||
|
|
||||||
const RESET_REQUEST_ATTEMPTS_PER_WINDOW = 5;
|
const RESET_REQUEST_ATTEMPTS_PER_WINDOW = 5;
|
||||||
const RESET_REQUEST_WINDOW_MS = 60 * 60 * 1000;
|
const RESET_REQUEST_WINDOW_MS = 60 * 60 * 1000;
|
||||||
@@ -28,6 +29,12 @@ export async function requestPasswordResetAction(formData: FormData): Promise<vo
|
|||||||
fail("/konto/passwort-vergessen", "Zu viele Anfragen. Bitte versuche es später erneut.");
|
fail("/konto/passwort-vergessen", "Zu viele Anfragen. Bitte versuche es später erneut.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CAPTCHA vor jeder weiteren Arbeit: haelt automatisierte Versuche ab,
|
||||||
|
// bevor sie Datenbank, Mailversand oder das Ratenlimit belasten.
|
||||||
|
if (!(await isTurnstileValid(formData, ip))) {
|
||||||
|
fail("/konto/passwort-vergessen", "Die Sicherheitsprüfung ist fehlgeschlagen. Bitte versuche es erneut.");
|
||||||
|
}
|
||||||
|
|
||||||
const parsed = requestPasswordResetSchema.safeParse({ email: formData.get("email") });
|
const parsed = requestPasswordResetSchema.safeParse({ email: formData.get("email") });
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
fail("/konto/passwort-vergessen", "Bitte gib eine gültige E-Mail-Adresse ein.");
|
fail("/konto/passwort-vergessen", "Bitte gib eine gültige E-Mail-Adresse ein.");
|
||||||
@@ -53,6 +60,12 @@ export async function resetPasswordAction(formData: FormData): Promise<void> {
|
|||||||
fail("/konto/passwort-vergessen", "Zu viele Anfragen. Bitte versuche es später erneut.");
|
fail("/konto/passwort-vergessen", "Zu viele Anfragen. Bitte versuche es später erneut.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CAPTCHA vor jeder weiteren Arbeit: haelt automatisierte Versuche ab,
|
||||||
|
// bevor sie Datenbank, Mailversand oder das Ratenlimit belasten.
|
||||||
|
if (!(await isTurnstileValid(formData, ip))) {
|
||||||
|
fail("/konto/passwort-vergessen", "Die Sicherheitsprüfung ist fehlgeschlagen. Bitte versuche es erneut.");
|
||||||
|
}
|
||||||
|
|
||||||
const parsed = resetPasswordSchema.safeParse({
|
const parsed = resetPasswordSchema.safeParse({
|
||||||
token: formData.get("token"),
|
token: formData.get("token"),
|
||||||
password: formData.get("password"),
|
password: formData.get("password"),
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ export default async function KontoPage(props: PageProps<"/konto">) {
|
|||||||
<Field name="email" label={t.account.emailLabel} type="email" required autoComplete="email" />
|
<Field name="email" label={t.account.emailLabel} type="email" required autoComplete="email" />
|
||||||
<PasswordField name="password" label={t.account.passwordLabel} required autoComplete="current-password" />
|
<PasswordField name="password" label={t.account.passwordLabel} required autoComplete="current-password" />
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
{process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY ? (
|
||||||
|
<TurnstileField siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY} />
|
||||||
|
) : null}
|
||||||
<Button type="submit">{t.account.login}</Button>
|
<Button type="submit">{t.account.login}</Button>
|
||||||
<Link href="/konto/passwort-vergessen" className="text-xs text-ink/65 hover:text-forest underline">
|
<Link href="/konto/passwort-vergessen" className="text-xs text-ink/65 hover:text-forest underline">
|
||||||
{t.account.forgotPassword}
|
{t.account.forgotPassword}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Container, SectionHeading, Button } from "@/components/ui";
|
import { Container, SectionHeading, Button } from "@/components/ui";
|
||||||
import { BackLink } from "@/components/back-link";
|
import { BackLink } from "@/components/back-link";
|
||||||
|
import { TurnstileField } from "@/components/turnstile-widget";
|
||||||
import { requestPasswordResetAction } from "../actions";
|
import { requestPasswordResetAction } from "../actions";
|
||||||
import { getLocale } from "@/i18n/locale";
|
import { getLocale } from "@/i18n/locale";
|
||||||
import { getDictionary } from "@/i18n/get-dictionary";
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
@@ -41,6 +42,9 @@ export default async function PasswortVergessenPage(props: PageProps<"/konto/pas
|
|||||||
className="mt-1 w-full border border-ink/20 bg-background px-4 py-3 focus:outline-none focus:border-forest"
|
className="mt-1 w-full border border-ink/20 bg-background px-4 py-3 focus:outline-none focus:border-forest"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
{process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY ? (
|
||||||
|
<TurnstileField siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY} />
|
||||||
|
) : null}
|
||||||
<Button type="submit">{t.passwortVergessenPage.requestLink}</Button>
|
<Button type="submit">{t.passwortVergessenPage.requestLink}</Button>
|
||||||
</form>
|
</form>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
|||||||
import { Container, SectionHeading, Button } from "@/components/ui";
|
import { Container, SectionHeading, Button } from "@/components/ui";
|
||||||
import { BackLink } from "@/components/back-link";
|
import { BackLink } from "@/components/back-link";
|
||||||
import { PasswordField } from "@/components/password-field";
|
import { PasswordField } from "@/components/password-field";
|
||||||
|
import { TurnstileField } from "@/components/turnstile-widget";
|
||||||
import { resetPasswordAction } from "../actions";
|
import { resetPasswordAction } from "../actions";
|
||||||
import { getLocale } from "@/i18n/locale";
|
import { getLocale } from "@/i18n/locale";
|
||||||
import { getDictionary } from "@/i18n/get-dictionary";
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
@@ -43,6 +44,9 @@ export default async function PasswortZuruecksetzenPage(
|
|||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
showStrength
|
showStrength
|
||||||
/>
|
/>
|
||||||
|
{process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY ? (
|
||||||
|
<TurnstileField siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY} />
|
||||||
|
) : null}
|
||||||
<Button type="submit">{t.passwortZuruecksetzenPage.save}</Button>
|
<Button type="submit">{t.passwortZuruecksetzenPage.save}</Button>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { Container, SectionHeading, Button } from "@/components/ui";
|
import { Container, SectionHeading, Button } from "@/components/ui";
|
||||||
import { BackLink } from "@/components/back-link";
|
import { BackLink } from "@/components/back-link";
|
||||||
|
import { TurnstileField } from "@/components/turnstile-widget";
|
||||||
import { getTwoFactorChallengeCustomerId } from "@/lib/auth";
|
import { getTwoFactorChallengeCustomerId } from "@/lib/auth";
|
||||||
import { verifyTwoFactorAction } from "../actions";
|
import { verifyTwoFactorAction } from "../actions";
|
||||||
import { getLocale } from "@/i18n/locale";
|
import { getLocale } from "@/i18n/locale";
|
||||||
@@ -44,6 +45,9 @@ export default async function ZweiFaktorCodePage(props: PageProps<"/konto/zwei-f
|
|||||||
className="mt-1 w-full border border-ink/20 bg-background px-4 py-3 tracking-widest focus:outline-none focus:border-forest"
|
className="mt-1 w-full border border-ink/20 bg-background px-4 py-3 tracking-widest focus:outline-none focus:border-forest"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
{process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY ? (
|
||||||
|
<TurnstileField siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY} />
|
||||||
|
) : null}
|
||||||
<Button type="submit">{t.twoFactorCodePage.confirm}</Button>
|
<Button type="submit">{t.twoFactorCodePage.confirm}</Button>
|
||||||
</form>
|
</form>
|
||||||
</Container>
|
</Container>
|
||||||
|
|||||||
+14
-1
@@ -5,7 +5,7 @@ const VERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
|
|||||||
// die CAPTCHA-Pruefung stillschweigend fuer alle Formulare zu ueberspringen.
|
// die CAPTCHA-Pruefung stillschweigend fuer alle Formulare zu ueberspringen.
|
||||||
if (process.env.NODE_ENV === "production" && !process.env.TURNSTILE_SECRET_KEY) {
|
if (process.env.NODE_ENV === "production" && !process.env.TURNSTILE_SECRET_KEY) {
|
||||||
console.warn(
|
console.warn(
|
||||||
"TURNSTILE_SECRET_KEY ist in Produktion nicht gesetzt — CAPTCHA-Pruefung auf Registrierung/Newsletter/Firmenanfrage ist deaktiviert."
|
"TURNSTILE_SECRET_KEY ist in Produktion nicht gesetzt — die CAPTCHA-Pruefung ist auf ALLEN oeffentlichen Formularen deaktiviert (Login, Zwei-Faktor, Passwort-Reset, Registrierung, Checkout, Newsletter, Firmenanfrage, Verfuegbarkeits-Benachrichtigung)."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,3 +38,16 @@ export async function verifyTurnstileToken(token: string | null, remoteIp?: stri
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liest das Turnstile-Token aus einem Formular und prueft es.
|
||||||
|
*
|
||||||
|
* Gemeinsame Stelle fuer alle Formulare, damit der Feldname nur einmal
|
||||||
|
* festgelegt ist: haette eine Seite ihn anders geschrieben, waere das Token
|
||||||
|
* leer angekommen — und weil ein leeres Token bei nicht konfiguriertem
|
||||||
|
* Secret durchgeht, waere der Schutz an genau dieser Stelle lautlos
|
||||||
|
* ausgefallen.
|
||||||
|
*/
|
||||||
|
export async function isTurnstileValid(formData: FormData, remoteIp?: string): Promise<boolean> {
|
||||||
|
return verifyTurnstileToken(String(formData.get("turnstileToken") ?? ""), remoteIp);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user