feat(auth): add email-based 2FA for unknown devices
All checks were successful
Staging Build / build (push) Successful in 2m54s
All checks were successful
Staging Build / build (push) Successful in 2m54s
This commit is contained in:
@@ -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<string | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
// 2FA state
|
||||
const [show2FA, setShow2FA] = useState(false);
|
||||
const [otpDigits, setOtpDigits] = useState<string[]>(["", "", "", "", "", ""]);
|
||||
const [userId, setUserId] = useState<string | null>(null);
|
||||
const [userRole, setUserRole] = useState<string | null>(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,9 +103,89 @@ 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 (
|
||||
<div className={cn("flex flex-col gap-6", className)} {...props}>
|
||||
<Card>
|
||||
<Card className="overflow-hidden">
|
||||
<AnimatePresence mode="wait">
|
||||
{!show2FA ? (
|
||||
<motion.div
|
||||
key="login"
|
||||
initial={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -40, filter: "blur(4px)" }}
|
||||
transition={{ duration: 0.35, ease: "easeInOut" }}
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl">Anmelden</CardTitle>
|
||||
<CardDescription>
|
||||
@@ -119,6 +241,107 @@ export function LoginForm({
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="2fa"
|
||||
initial={{ opacity: 0, x: 40, filter: "blur(4px)" }}
|
||||
animate={{ opacity: 1, x: 0, filter: "blur(0px)" }}
|
||||
exit={{ opacity: 0, x: -40 }}
|
||||
transition={{ duration: 0.35, ease: "easeInOut" }}
|
||||
>
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-3 flex h-14 w-14 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900/30">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-blue-600 dark:text-blue-400">
|
||||
<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/>
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
||||
</svg>
|
||||
</div>
|
||||
<CardTitle className="text-2xl">Sicherheitscode</CardTitle>
|
||||
<CardDescription className="text-sm mt-1">
|
||||
Wir haben einen 6-stelligen Code an<br />
|
||||
<span className="font-semibold text-foreground">{email}</span><br />
|
||||
gesendet.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* OTP Input */}
|
||||
<div className="flex justify-center gap-2" onPaste={handleOtpPaste}>
|
||||
{otpDigits.map((digit, i) => (
|
||||
<input
|
||||
key={i}
|
||||
ref={(el) => { 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"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: -8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="text-sm text-red-500 text-center bg-red-500/10 border border-red-500/20 p-3 rounded-lg font-medium"
|
||||
>
|
||||
{error}
|
||||
</motion.p>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full"
|
||||
disabled={isLoading || otpDigits.some(d => !d)}
|
||||
onClick={() => handleVerify2FA(otpDigits.join(""))}
|
||||
>
|
||||
{isLoading ? "Wird überprüft..." : "Bestätigen"}
|
||||
</Button>
|
||||
|
||||
<div className="text-center text-sm text-muted-foreground">
|
||||
Code nicht erhalten?{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleResend}
|
||||
disabled={resendCooldown > 0}
|
||||
className={cn(
|
||||
"font-medium underline-offset-4 hover:underline transition-colors",
|
||||
resendCooldown > 0
|
||||
? "text-muted-foreground/50 cursor-not-allowed"
|
||||
: "text-blue-600 hover:text-blue-700 dark:text-blue-400"
|
||||
)}
|
||||
>
|
||||
{resendCooldown > 0 ? `Erneut senden (${resendCooldown}s)` : "Erneut senden"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShow2FA(false);
|
||||
setError(null);
|
||||
setOtpDigits(["", "", "", "", "", ""]);
|
||||
}}
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors text-center"
|
||||
>
|
||||
← Zurück zur Anmeldung
|
||||
</button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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 =>
|
||||
`<td style="width: 48px; height: 56px; text-align: center; vertical-align: middle; font-size: 28px; font-weight: 800; color: #1e293b; background-color: #f1f5f9; border: 2px solid #e2e8f0; border-radius: 12px; font-family: 'Courier New', monospace; letter-spacing: 0;">${d}</td>`
|
||||
).join('<td style="width: 8px;"></td>')
|
||||
|
||||
const messageHtml = `
|
||||
<p style="color: #475569; font-size: 16px; line-height: 1.6; margin-top: 0;">Hallo,</p>
|
||||
<p style="color: #475569; font-size: 16px; line-height: 1.6;">
|
||||
Wir haben eine Anmeldung von einem neuen Gerät erkannt. Bitte bestätigen Sie Ihre Identität mit folgendem Sicherheitscode:
|
||||
</p>
|
||||
|
||||
<div style="text-align: center; margin: 32px 0;">
|
||||
<table cellpadding="0" cellspacing="0" style="margin: 0 auto;">
|
||||
<tr>${digitBoxes}</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div style="background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%); border-radius: 12px; padding: 16px 20px; margin: 24px 0; border-left: 4px solid #f59e0b;">
|
||||
<p style="color: #92400e; font-size: 14px; margin: 0; line-height: 1.5;">
|
||||
⏱️ <strong>Dieser Code ist 15 Minuten gültig.</strong><br>
|
||||
Falls Sie diese Anmeldung nicht durchgeführt haben, ändern Sie bitte umgehend Ihr Passwort.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p style="color: #64748b; font-size: 13px; line-height: 1.6; margin-top: 24px;">
|
||||
🔒 Dieser Code dient der Sicherheit Ihres Kontos. Geben Sie ihn niemals an andere Personen weiter.
|
||||
</p>
|
||||
`
|
||||
|
||||
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()
|
||||
|
||||
36
shop/supabase/migrations/20260811000000_device_2fa.sql
Normal file
36
shop/supabase/migrations/20260811000000_device_2fa.sql
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user