From 883b6074823c7fd9fdd396e1a1ee4a0f55a27ca2 Mon Sep 17 00:00:00 2001 From: DanielS Date: Tue, 11 Aug 2026 01:10:28 +0200 Subject: [PATCH] feat(auth): add email-based 2FA for unknown devices --- shop/components/login-form.tsx | 355 ++++++++++++++---- shop/lib/actions/auth.ts | 169 ++++++++- .../migrations/20260811000000_device_2fa.sql | 36 ++ 3 files changed, 492 insertions(+), 68 deletions(-) create mode 100644 shop/supabase/migrations/20260811000000_device_2fa.sql diff --git a/shop/components/login-form.tsx b/shop/components/login-form.tsx index e702f29..6d018fc 100644 --- a/shop/components/login-form.tsx +++ b/shop/components/login-form.tsx @@ -1,7 +1,7 @@ "use client"; import { cn } from "@/lib/utils"; -import { signIn } from "@/lib/actions/auth"; +import { signIn, verifyDevice2FA, resend2FACode } from "@/lib/actions/auth"; import { Button } from "@/components/ui/button"; import { Card, @@ -14,7 +14,18 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import Link from "next/link"; import { useRouter } from "next/navigation"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef, useCallback } from "react"; +import { motion, AnimatePresence } from "framer-motion"; + +function getDeviceHash(): string { + const key = "caspos-device-id"; + let id = localStorage.getItem(key); + if (!id) { + id = crypto.randomUUID(); + localStorage.setItem(key, id); + } + return id; +} export function LoginForm({ className, @@ -28,32 +39,63 @@ export function LoginForm({ const [errorParam, setErrorParam] = useState(null); const router = useRouter(); + // 2FA state + const [show2FA, setShow2FA] = useState(false); + const [otpDigits, setOtpDigits] = useState(["", "", "", "", "", ""]); + const [userId, setUserId] = useState(null); + const [userRole, setUserRole] = useState(null); + const [resendCooldown, setResendCooldown] = useState(0); + const inputRefs = useRef<(HTMLInputElement | null)[]>([]); + useEffect(() => { const params = new URLSearchParams(window.location.search); setMessageParam(params.get('message')); setErrorParam(params.get('error')); }, []); + // Resend cooldown timer + useEffect(() => { + if (resendCooldown <= 0) return; + const timer = setTimeout(() => setResendCooldown(c => c - 1), 1000); + return () => clearTimeout(timer); + }, [resendCooldown]); + + const navigateAfterLogin = useCallback((role: string) => { + const nextParam = typeof window !== 'undefined' ? new URLSearchParams(window.location.search).get('next') : null; + if (nextParam) { + router.push(nextParam); + } else if (role === "admin") { + router.push("/admin/einstellungen"); + } else { + router.push("/my-customers"); + } + }, [router]); + const handleLogin = async (e: React.FormEvent) => { e.preventDefault(); setIsLoading(true); setError(null); - const nextParam = typeof window !== 'undefined' ? new URLSearchParams(window.location.search).get('next') : null; - try { - const res = await signIn(email, password); + const deviceHash = getDeviceHash(); + const res = await signIn(email, password, deviceHash); + if (!res.success) { setError(res.error || "Ein Fehler ist aufgetreten."); return; } - if (nextParam) { - router.push(nextParam); - } else if (res.role === "admin") { - router.push("/admin/einstellungen"); - } else { - router.push("/my-customers"); + + if (res.requires2FA) { + setUserId(res.userId || null); + setUserRole(res.role || null); + setShow2FA(true); + setResendCooldown(60); + setOtpDigits(["", "", "", "", "", ""]); + setTimeout(() => inputRefs.current[0]?.focus(), 300); + return; } + + navigateAfterLogin(res.role || "partner"); } catch (error: unknown) { setError(error instanceof Error ? error.message : "Ein Fehler ist aufgetreten."); } finally { @@ -61,64 +103,245 @@ export function LoginForm({ } }; + const handleOtpChange = (index: number, value: string) => { + if (!/^\d*$/.test(value)) return; + const newDigits = [...otpDigits]; + newDigits[index] = value.slice(-1); + setOtpDigits(newDigits); + + // Auto-advance + if (value && index < 5) { + inputRefs.current[index + 1]?.focus(); + } + + // Auto-submit when all filled + if (newDigits.every(d => d !== "") && newDigits.join("").length === 6) { + handleVerify2FA(newDigits.join("")); + } + }; + + const handleOtpKeyDown = (index: number, e: React.KeyboardEvent) => { + if (e.key === "Backspace" && !otpDigits[index] && index > 0) { + inputRefs.current[index - 1]?.focus(); + } + }; + + const handleOtpPaste = (e: React.ClipboardEvent) => { + e.preventDefault(); + const paste = e.clipboardData.getData("text").replace(/\D/g, "").slice(0, 6); + if (paste.length === 6) { + const newDigits = paste.split(""); + setOtpDigits(newDigits); + inputRefs.current[5]?.focus(); + handleVerify2FA(paste); + } + }; + + const handleVerify2FA = async (code: string) => { + if (!userId) return; + setIsLoading(true); + setError(null); + + try { + const deviceHash = getDeviceHash(); + const res = await verifyDevice2FA(userId, code, deviceHash); + + if (!res.success) { + setError(res.error || "Ungültiger Code."); + setOtpDigits(["", "", "", "", "", ""]); + inputRefs.current[0]?.focus(); + return; + } + + navigateAfterLogin(res.role || userRole || "partner"); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : "Fehler bei der Verifizierung."); + setOtpDigits(["", "", "", "", "", ""]); + inputRefs.current[0]?.focus(); + } finally { + setIsLoading(false); + } + }; + + const handleResend = async () => { + if (!userId || resendCooldown > 0) return; + setError(null); + const deviceHash = getDeviceHash(); + const res = await resend2FACode(userId, deviceHash); + if (res.success) { + setResendCooldown(60); + } else { + setError(res.error || "Fehler beim erneuten Senden."); + } + }; + return (
- - - Anmelden - - Geben Sie Ihre E-Mail-Adresse und Ihr Passwort ein, um sich anzumelden. - - - -
-
-
- - setEmail(e.target.value)} - /> -
-
-
- - - Passwort vergessen? - + + + {!show2FA ? ( + + + Anmelden + + Geben Sie Ihre E-Mail-Adresse und Ihr Passwort ein, um sich anzumelden. + + + + +
+
+ + setEmail(e.target.value)} + /> +
+
+
+ + + Passwort vergessen? + +
+ setPassword(e.target.value)} + /> +
+ {errorParam === "gesperrt" && ( +

+ 🚫 Ihr Konto wurde gesperrt. Bitte wenden Sie sich an den Administrator. +

+ )} + {messageParam === "concurrent" && ( +

+ Sie wurden abgemeldet, da Sie sich an einem anderen Gerät angemeldet haben. +

+ )} + {error &&

{error}

} + +
+ +
+
+ ) : ( + + +
+ + + +
- setPassword(e.target.value)} - /> -
- {errorParam === "gesperrt" && ( -

- 🚫 Ihr Konto wurde gesperrt. Bitte wenden Sie sich an den Administrator. -

- )} - {messageParam === "concurrent" && ( -

- Sie wurden abgemeldet, da Sie sich an einem anderen Gerät angemeldet haben. -

- )} - {error &&

{error}

} - -
- - + Sicherheitscode + + Wir haben einen 6-stelligen Code an
+ {email}
+ gesendet. +
+ + +
+ {/* OTP Input */} +
+ {otpDigits.map((digit, i) => ( + { inputRefs.current[i] = el; }} + type="text" + inputMode="numeric" + maxLength={1} + value={digit} + onChange={(e) => handleOtpChange(i, e.target.value)} + onKeyDown={(e) => handleOtpKeyDown(i, e)} + className={cn( + "h-14 w-11 rounded-xl border-2 bg-muted/50 text-center text-xl font-bold transition-all duration-200", + "focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 focus:outline-none", + "hover:border-muted-foreground/40", + digit ? "border-blue-400 bg-blue-50 dark:bg-blue-950/30" : "border-muted-foreground/20" + )} + id={`otp-${i}`} + autoComplete="one-time-code" + /> + ))} +
+ + {error && ( + + {error} + + )} + + + +
+ Code nicht erhalten?{" "} + +
+ + +
+
+ + )} +
); diff --git a/shop/lib/actions/auth.ts b/shop/lib/actions/auth.ts index d8436d3..aaee1ce 100644 --- a/shop/lib/actions/auth.ts +++ b/shop/lib/actions/auth.ts @@ -5,8 +5,9 @@ import { createAdminClient } from '@/lib/supabase/admin' import { headers } from 'next/headers' import { sendLockoutEmail } from '@/lib/utils/email' import { sendMail } from '@/utils/mail' +import crypto from 'crypto' -export async function signIn(email: string, password: string) { +export async function signIn(email: string, password: string, deviceHash?: string) { try { const supabase = await createClient() const { data, error } = await supabase.auth.signInWithPassword({ @@ -91,7 +92,7 @@ export async function signIn(email: string, password: string) { } } - if (userError || !userData || (userData.role !== 'partner' && userData.role !== 'admin')) { + if (userError || !userData || (userData.role !== 'partner' && userData.role !== 'admin' && userData.role !== 'verwaltung')) { await supabase.auth.signOut() if (userData?.role === 'gesperrt') { return { success: false, error: "Ihr Konto wurde gesperrt. Bitte wenden Sie sich an den Administrator." } @@ -99,6 +100,23 @@ export async function signIn(email: string, password: string) { return { success: false, error: "Zugriff verweigert. Nur registrierte Partner dürfen sich anmelden." } } + // --- 2FA: Geräteprüfung --- + if (deviceHash) { + const { data: knownDevice } = await adminClient + .from('known_devices') + .select('id') + .eq('user_id', userId) + .eq('device_hash', deviceHash) + .maybeSingle() + + if (!knownDevice) { + // Gerät unbekannt -> 2FA erforderlich + // Session bleibt aktiv, aber Client muss 2FA bestätigen + await send2FACodeInternal(userId, deviceHash, email) + return { success: true, requires2FA: true, userId, role: userData.role } + } + } + // Andere aktive Sitzungen beenden await supabase.auth.signOut({ scope: 'others' }) @@ -161,6 +179,153 @@ function getBeautifulEmailHtml(title: string, messageHtml: string, buttonText?: `; } +function get2FAEmailHtml(code: string) { + const digits = code.split('') + const digitBoxes = digits.map(d => + `${d}` + ).join('') + + const messageHtml = ` +

Hallo,

+

+ Wir haben eine Anmeldung von einem neuen Gerät erkannt. Bitte bestätigen Sie Ihre Identität mit folgendem Sicherheitscode: +

+ +
+ + ${digitBoxes} +
+
+ +
+

+ ⏱️ Dieser Code ist 15 Minuten gültig.
+ Falls Sie diese Anmeldung nicht durchgeführt haben, ändern Sie bitte umgehend Ihr Passwort. +

+
+ +

+ 🔒 Dieser Code dient der Sicherheit Ihres Kontos. Geben Sie ihn niemals an andere Personen weiter. +

+ ` + + return getBeautifulEmailHtml('🔐 Sicherheitscode', messageHtml) +} + +/** Internal: generates code + sends email (no auth check needed) */ +async function send2FACodeInternal(userId: string, deviceHash: string, email: string) { + const adminClient = createAdminClient() + const code = Math.floor(100000 + Math.random() * 900000).toString() + const expiresAt = new Date(Date.now() + 15 * 60 * 1000).toISOString() + + // Alte Codes für dieses Gerät löschen + await adminClient + .from('device_verification_codes') + .delete() + .eq('user_id', userId) + .eq('device_hash', deviceHash) + + // Neuen Code speichern + await adminClient + .from('device_verification_codes') + .insert({ user_id: userId, code, device_hash: deviceHash, expires_at: expiresAt }) + + // Mail senden + await sendMail({ + to: email, + subject: '🔐 Ihr Sicherheitscode – CASPOS Shop', + text: `Ihr Sicherheitscode lautet: ${code}\n\nDieser Code ist 15 Minuten gültig.\n\nFalls Sie diese Anmeldung nicht durchgeführt haben, ändern Sie bitte umgehend Ihr Passwort.`, + html: get2FAEmailHtml(code), + }) +} + +/** Public server action: resend 2FA code */ +export async function resend2FACode(userId: string, deviceHash: string) { + try { + const adminClient = createAdminClient() + const { data: user } = await adminClient + .from('users') + .select('email') + .eq('id', userId) + .single() + + if (!user?.email) return { success: false, error: 'Benutzer nicht gefunden.' } + + await send2FACodeInternal(userId, deviceHash, user.email) + return { success: true } + } catch (err: any) { + console.error('Resend 2FA error:', err) + return { success: false, error: 'Fehler beim erneuten Senden des Codes.' } + } +} + +/** Verify 2FA code and register device */ +export async function verifyDevice2FA(userId: string, code: string, deviceHash: string) { + try { + const adminClient = createAdminClient() + + // Code prüfen + const { data: codeEntry, error: codeError } = await adminClient + .from('device_verification_codes') + .select('*') + .eq('user_id', userId) + .eq('device_hash', deviceHash) + .eq('code', code) + .maybeSingle() + + if (codeError || !codeEntry) { + return { success: false, error: 'Ungültiger Sicherheitscode.' } + } + + // Ablauf prüfen + if (new Date(codeEntry.expires_at) < new Date()) { + // Code abgelaufen -> löschen + await adminClient + .from('device_verification_codes') + .delete() + .eq('id', codeEntry.id) + return { success: false, error: 'Der Code ist abgelaufen. Bitte fordern Sie einen neuen Code an.' } + } + + // Gerät registrieren + const headersList = await headers() + const userAgent = headersList.get('user-agent') || '' + const ip = headersList.get('x-forwarded-for')?.split(',')[0]?.trim() || headersList.get('x-real-ip') || '' + + await adminClient + .from('known_devices') + .upsert({ + user_id: userId, + device_hash: deviceHash, + user_agent: userAgent, + ip_address: ip, + verified_at: new Date().toISOString(), + }, { onConflict: 'user_id,device_hash' }) + + // Verbrauchten Code löschen + await adminClient + .from('device_verification_codes') + .delete() + .eq('id', codeEntry.id) + + // Andere Sessions beenden + const supabase = await createClient() + await supabase.auth.signOut({ scope: 'others' }) + + // Rolle zurückgeben + const { data: userData } = await adminClient + .from('users') + .select('role') + .eq('id', userId) + .single() + + return { success: true, role: userData?.role || 'partner' } + } catch (err: any) { + console.error('Verify 2FA error:', err) + return { success: false, error: 'Fehler bei der Verifizierung.' } + } +} + export async function resetPassword(email: string) { try { const admin = createAdminClient() diff --git a/shop/supabase/migrations/20260811000000_device_2fa.sql b/shop/supabase/migrations/20260811000000_device_2fa.sql new file mode 100644 index 0000000..75ad950 --- /dev/null +++ b/shop/supabase/migrations/20260811000000_device_2fa.sql @@ -0,0 +1,36 @@ +-- Tabelle für bekannte, verifizierte Geräte +CREATE TABLE IF NOT EXISTS public.known_devices ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + device_hash TEXT NOT NULL, + ip_address TEXT, + user_agent TEXT, + verified_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + UNIQUE (user_id, device_hash) +); + +ALTER TABLE public.known_devices ENABLE ROW LEVEL SECURITY; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_policies WHERE tablename = 'known_devices' AND policyname = 'Users can manage own devices' + ) THEN + CREATE POLICY "Users can manage own devices" ON public.known_devices + FOR ALL USING (auth.uid() = user_id); + END IF; +END $$; + +-- Tabelle für temporäre OTP-Verifizierungscodes (ohne RLS-Zutritt für Clients) +CREATE TABLE IF NOT EXISTS public.device_verification_codes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + code TEXT NOT NULL, + device_hash TEXT NOT NULL, + expires_at TIMESTAMP WITH TIME ZONE NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +ALTER TABLE public.device_verification_codes ENABLE ROW LEVEL SECURITY; +-- Keine Policies -> Nur serverseitiger Zugriff über Admin-Client