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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user