Files
webshop/shop/lib/actions/auth.ts

382 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use server'
import { createClient } from '@/lib/supabase/server'
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, deviceHash?: string) {
try {
const supabase = await createClient()
const { data, error } = await supabase.auth.signInWithPassword({
email,
password,
})
if (error) {
return { success: false, error: error.message }
}
const userId = data.user?.id
if (userId) {
const adminClient = createAdminClient()
let { data: userData, error: userError } = await adminClient
.from('users')
.select('role')
.eq('id', userId)
.single()
if (userError || !userData) {
// Fallback: create database records if missing for this authenticated user
try {
const adminSupabase = createAdminClient()
// Ensure profile exists
await adminSupabase.from('profiles').insert([{ id: userId }]).select()
// Ensure user exists
const { data: insertedUser, error: insertError } = await adminSupabase
.from('users')
.insert([{ id: userId, role: 'partner' }])
.select('role')
.single()
if (!insertError && insertedUser) {
userData = insertedUser
userError = null
}
} catch (adminErr) {
console.error("Failed to backfill user records:", adminErr)
}
}
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." }
}
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' })
return { success: true, role: userData.role }
}
return { success: true, role: 'partner' }
} catch (err: any) {
console.error("SignIn error:", err)
return { success: false, error: err?.message || "Ein unerwarteter Fehler ist aufgetreten." }
}
}
export async function signUp(email: string, password: string) {
throw new Error("Registrierung ist deaktiviert.")
}
export async function signOut() {
const supabase = await createClient()
const { error } = await supabase.auth.signOut()
if (error) {
throw new Error(error.message)
}
return { success: true }
}
function getBeautifulEmailHtml(title: string, messageHtml: string, buttonText?: string, buttonUrl?: string) {
return `
<div style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; background-color: #f8fafc; padding: 40px 10px; margin: 0; width: 100%;">
<div style="max-width: 600px; margin: 0 auto; background-color: #ffffff; border-radius: 16px; overflow: hidden; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -2px rgba(0, 0, 0, 0.05); border: 1px solid #e2e8f0;">
<!-- Header banner with gradient -->
<div style="background: linear-gradient(135deg, #1e3a8a 0%, #312e81 100%); padding: 32px; text-align: center;">
<h1 style="color: #ffffff; margin: 0; font-size: 24px; font-weight: 800; letter-spacing: -0.5px;">${title}</h1>
</div>
<!-- Content -->
<div style="padding: 40px 32px; background-color: #ffffff;">
${messageHtml}
${buttonText && buttonUrl ? `
<div style="text-align: center; margin: 32px 0;">
<a href="${buttonUrl}" style="background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%); color: #ffffff; padding: 14px 28px; text-decoration: none; border-radius: 8px; font-weight: bold; font-size: 15px; display: inline-block; box-shadow: 0 4px 10px rgba(37, 99, 235, 0.2); transition: all 0.2s ease;">
${buttonText}
</a>
</div>
<p style="color: #64748b; font-size: 13px; line-height: 1.6; margin-top: 32px; border-top: 1px solid #f1f5f9; padding-top: 20px;">
Falls der Button nicht funktioniert, kopieren Sie bitte diesen Link in Ihren Browser:<br>
<a href="${buttonUrl}" style="color: #2563eb; text-decoration: none; word-break: break-all;">${buttonUrl}</a>
</p>
` : ''}
<hr style="border: 0; border-top: 1px solid #f1f5f9; margin: 32px 0;">
<p style="color: #94a3b8; font-size: 11px; text-align: center; margin: 0; line-height: 1.6;">
Dies ist eine automatisch generierte E-Mail.<br>
© ${new Date().getFullYear()} CASPOS Shop. Alle Rechte vorbehalten.
</p>
</div>
</div>
</div>
`;
}
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
const { data: insData, error: insError } = await adminClient
.from('device_verification_codes')
.insert({ user_id: userId, code, device_hash: deviceHash, expires_at: expiresAt })
.select()
if (insError) {
console.error("2FA CODE INSERT ERROR:", insError)
} else {
console.log("2FA CODE INSERTED:", code, insData)
}
// 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: authUser, error: authError } = await adminClient.auth.admin.getUserById(userId)
const email = authUser?.user?.email
if (authError || !email) return { success: false, error: 'Benutzer-E-Mail nicht gefunden.' }
await send2FACodeInternal(userId, deviceHash, 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()
const cleanCode = code.trim()
// Code prüfen (unter Berücksichtigung von Leerzeichen & device_hash)
console.log("VERIFYING 2FA: userId =", userId, "code =", cleanCode)
const { data: codeEntries, error: codeError } = await adminClient
.from('device_verification_codes')
.select('*')
.eq('user_id', userId)
.eq('code', cleanCode)
.order('created_at', { ascending: false })
.limit(1)
const codeEntry = codeEntries && codeEntries.length > 0 ? codeEntries[0] : null
console.log("VERIFY 2FA DB RESULT: entry =", codeEntry, "error =", codeError)
if (codeError || !codeEntry) {
// Doppelklick- & Race-Condition Schutz: Prüfen ob das Gerät eben bereits verifiziert wurde
const { data: knownDev } = await adminClient
.from('known_devices')
.select('*')
.eq('user_id', userId)
.limit(1)
if (knownDev && knownDev.length > 0) {
const { data: userData } = await adminClient
.from('users')
.select('role')
.eq('id', userId)
.single()
return { success: true, role: userData?.role || 'partner' }
}
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') || ''
const targetDeviceHash = deviceHash || codeEntry.device_hash || 'default-device'
await adminClient
.from('known_devices')
.upsert({
user_id: userId,
device_hash: targetDeviceHash,
user_agent: userAgent,
ip_address: ip,
verified_at: new Date().toISOString(),
}, { onConflict: 'user_id,device_hash' })
// Verbrauchte Codes für diesen User löschen
await adminClient
.from('device_verification_codes')
.delete()
.eq('user_id', userId)
// 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 Code-Überprüfung.' }
}
}
export async function resetPassword(email: string) {
try {
const admin = createAdminClient()
const { data, error } = await admin.auth.admin.generateLink({
type: 'recovery',
email,
})
if (error) {
return { success: false, error: error.message }
}
const tokenHash = (data as any)?.properties?.hashed_token;
if (!tokenHash) {
return { success: false, error: 'Wiederherstellungs-Token konnte nicht generiert werden.' }
}
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://staging.hephex.de'
const resetLink = `${siteUrl}/auth/verify-link?token_hash=${tokenHash}&type=recovery&next=/auth/update-password`
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;">Sie haben das Zurücksetzen Ihres Passworts angefordert.</p>
<p style="color: #475569; font-size: 16px; line-height: 1.6;">Bitte klicken Sie auf den untenstehenden Button, um ein neues Passwort festzulegen:</p>
`;
await sendMail({
to: email,
subject: 'Passwort zurücksetzen CASPOS Shop',
text: `Hallo,\n\nSie haben das Zurücksetzen Ihres Passworts angefordert.\n\nBitte klicken Sie auf den folgenden Link, um ein neues Passwort festzulegen:\n${resetLink}\n\nViele Grüße,\nIhr CASPOS Shop-Team`,
html: getBeautifulEmailHtml('Passwort zurücksetzen', messageHtml, 'Passwort zurücksetzen', resetLink)
})
return { success: true }
} catch (err: any) {
console.error('Password reset error:', err)
return { success: false, error: err.message || 'Fehler beim Senden der Passwort-Zurücksetzen-E-Mail.' }
}
}
export async function updatePassword(password: string) {
try {
const supabase = await createClient()
const { error } = await supabase.auth.updateUser({ password })
if (error) {
return { success: false, error: error.message }
}
return { success: true }
} catch (err: any) {
console.error('Update password error:', err)
return { success: false, error: err.message || 'Fehler beim Aktualisieren des Passworts.' }
}
}
export async function verifyAdmin() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) throw new Error("Nicht authentifiziert.")
const { data: userData, error } = await supabase
.from('users')
.select('role')
.eq('id', user.id)
.single()
if (error || !userData || userData.role !== 'admin') {
throw new Error("Zugriff verweigert. Nur Administratoren erlaubt.")
}
return user
}