Forráskód Böngészése

implementing email otp before form input

Medowar 3 hete
szülő
commit
efa6edd512

+ 15 - 0
api/delete-upload.php

@@ -4,6 +4,7 @@ declare(strict_types=1);
 
 use App\App\Bootstrap;
 use App\Security\Csrf;
+use App\Security\FormAccess;
 use App\Security\RateLimiter;
 use App\Storage\FileSystem;
 use App\Storage\JsonStore;
@@ -29,6 +30,20 @@ if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
     Bootstrap::jsonResponse(['ok' => false, 'message' => 'Bitte gültige E-Mail eingeben.'], 422);
 }
 
+$activityRaw = $_POST['last_user_activity_at'] ?? null;
+$lastUserActivityAt = is_scalar($activityRaw) ? (int) $activityRaw : null;
+$formAccess = new FormAccess();
+$auth = $formAccess->assertVerifiedForEmail($email, $lastUserActivityAt);
+if (($auth['ok'] ?? false) !== true) {
+    $reason = (string) ($auth['reason'] ?? '');
+    Bootstrap::jsonResponse([
+        'ok' => false,
+        'message' => (string) ($auth['message'] ?? 'Bitte E-Mail erneut verifizieren.'),
+        'auth_required' => $reason === 'auth_required',
+        'auth_expired' => $reason === 'auth_expired',
+    ], (int) ($auth['status_code'] ?? 401));
+}
+
 $field = trim((string) ($_POST['field'] ?? ''));
 $index = (int) ($_POST['index'] ?? -1);
 if ($field === '' || $index < 0) {

+ 15 - 0
api/load-draft.php

@@ -5,6 +5,7 @@ declare(strict_types=1);
 use App\App\Bootstrap;
 use App\Storage\JsonStore;
 use App\Security\Csrf;
+use App\Security\FormAccess;
 use App\Security\RateLimiter;
 
 require dirname(__DIR__) . '/src/autoload.php';
@@ -29,6 +30,20 @@ if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
     Bootstrap::jsonResponse(['ok' => false, 'message' => 'Bitte gültige E-Mail eingeben.'], 422);
 }
 
+$activityRaw = $_POST['last_user_activity_at'] ?? null;
+$lastUserActivityAt = is_scalar($activityRaw) ? (int) $activityRaw : null;
+$formAccess = new FormAccess();
+$auth = $formAccess->assertVerifiedForEmail($email, $lastUserActivityAt);
+if (($auth['ok'] ?? false) !== true) {
+    $reason = (string) ($auth['reason'] ?? '');
+    Bootstrap::jsonResponse([
+        'ok' => false,
+        'message' => (string) ($auth['message'] ?? 'Bitte E-Mail erneut verifizieren.'),
+        'auth_required' => $reason === 'auth_required',
+        'auth_expired' => $reason === 'auth_expired',
+    ], (int) ($auth['status_code'] ?? 401));
+}
+
 $app = Bootstrap::config('app');
 $limiter = new RateLimiter();
 $ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';

+ 78 - 0
api/request-otp.php

@@ -0,0 +1,78 @@
+<?php
+
+declare(strict_types=1);
+
+use App\App\Bootstrap;
+use App\Mail\Mailer;
+use App\Security\Csrf;
+use App\Security\FormAccess;
+use App\Security\RateLimiter;
+
+require dirname(__DIR__) . '/src/autoload.php';
+Bootstrap::init();
+
+if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
+    Bootstrap::jsonResponse(['ok' => false, 'message' => 'Method not allowed'], 405);
+}
+
+$csrf = $_POST['csrf'] ?? '';
+if (!Csrf::validate(is_string($csrf) ? $csrf : null)) {
+    Bootstrap::jsonResponse(['ok' => false, 'message' => 'Ungueltiges CSRF-Token.'], 419);
+}
+
+if (trim((string) ($_POST['website'] ?? '')) !== '') {
+    Bootstrap::jsonResponse(['ok' => false, 'message' => 'Anfrage blockiert.'], 400);
+}
+
+$email = strtolower(trim((string) ($_POST['email'] ?? '')));
+if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
+    Bootstrap::jsonResponse(['ok' => false, 'message' => 'Bitte gueltige E-Mail eingeben.'], 422);
+}
+
+$autoStartRaw = strtolower(trim((string) ($_POST['auto_start'] ?? '0')));
+$autoStart = in_array($autoStartRaw, ['1', 'true', 'yes'], true);
+
+$app = Bootstrap::config('app');
+$limiter = new RateLimiter();
+$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
+$rateKey = sprintf('otp-request:%s:%s', $ip, $email);
+if (!$limiter->allow($rateKey, (int) $app['rate_limit']['requests'], (int) $app['rate_limit']['window_seconds'])) {
+    Bootstrap::jsonResponse(['ok' => false, 'message' => 'Zu viele Anfragen. Bitte spaeter erneut versuchen.'], 429);
+}
+
+$formAccess = new FormAccess();
+$request = $formAccess->requestOtp($email, $autoStart);
+if (($request['ok'] ?? false) !== true) {
+    Bootstrap::jsonResponse([
+        'ok' => false,
+        'message' => (string) ($request['message'] ?? 'Code konnte nicht angefordert werden.'),
+        'retry_after' => (int) ($request['retry_after'] ?? 0),
+    ], (int) ($request['status_code'] ?? 422));
+}
+
+if (($request['auto_skipped'] ?? false) === true) {
+    Bootstrap::jsonResponse([
+        'ok' => true,
+        'auto_skipped' => true,
+        'message' => 'Automatische Code-Anfrage in dieser Sitzung bereits erfolgt.',
+    ]);
+}
+
+$otpCode = (string) ($request['code'] ?? '');
+$ttlSeconds = (int) ($request['expires_in'] ?? $formAccess->otpTtlSeconds());
+
+$mailer = new Mailer();
+if (!$mailer->sendOtpMail($email, $otpCode, $ttlSeconds)) {
+    $formAccess->clearPendingOtp();
+    Bootstrap::jsonResponse([
+        'ok' => false,
+        'message' => 'Code konnte nicht per E-Mail gesendet werden. Bitte spaeter erneut versuchen.',
+    ], 500);
+}
+
+Bootstrap::jsonResponse([
+    'ok' => true,
+    'message' => 'Sicherheitscode wurde per E-Mail versendet.',
+    'expires_in' => $ttlSeconds,
+    'resend_available_in' => (int) ($request['cooldown_seconds'] ?? $formAccess->resendCooldownSeconds()),
+]);

+ 15 - 0
api/reset.php

@@ -4,6 +4,7 @@ declare(strict_types=1);
 
 use App\App\Bootstrap;
 use App\Security\Csrf;
+use App\Security\FormAccess;
 use App\Security\RateLimiter;
 use App\Storage\FileSystem;
 use App\Storage\JsonStore;
@@ -29,6 +30,20 @@ if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
     Bootstrap::jsonResponse(['ok' => false, 'message' => 'Bitte gültige E-Mail eingeben.'], 422);
 }
 
+$activityRaw = $_POST['last_user_activity_at'] ?? null;
+$lastUserActivityAt = is_scalar($activityRaw) ? (int) $activityRaw : null;
+$formAccess = new FormAccess();
+$auth = $formAccess->assertVerifiedForEmail($email, $lastUserActivityAt);
+if (($auth['ok'] ?? false) !== true) {
+    $reason = (string) ($auth['reason'] ?? '');
+    Bootstrap::jsonResponse([
+        'ok' => false,
+        'message' => (string) ($auth['message'] ?? 'Bitte E-Mail erneut verifizieren.'),
+        'auth_required' => $reason === 'auth_required',
+        'auth_expired' => $reason === 'auth_expired',
+    ], (int) ($auth['status_code'] ?? 401));
+}
+
 $app = Bootstrap::config('app');
 $limiter = new RateLimiter();
 $ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';

+ 15 - 0
api/save-draft.php

@@ -7,6 +7,7 @@ use App\Form\FormSchema;
 use App\Storage\FileUploadStore;
 use App\Storage\JsonStore;
 use App\Security\Csrf;
+use App\Security\FormAccess;
 use App\Security\RateLimiter;
 
 require dirname(__DIR__) . '/src/autoload.php';
@@ -30,6 +31,20 @@ if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
     Bootstrap::jsonResponse(['ok' => false, 'message' => 'Bitte gültige E-Mail eingeben.'], 422);
 }
 
+$activityRaw = $_POST['last_user_activity_at'] ?? null;
+$lastUserActivityAt = is_scalar($activityRaw) ? (int) $activityRaw : null;
+$formAccess = new FormAccess();
+$auth = $formAccess->assertVerifiedForEmail($email, $lastUserActivityAt);
+if (($auth['ok'] ?? false) !== true) {
+    $reason = (string) ($auth['reason'] ?? '');
+    Bootstrap::jsonResponse([
+        'ok' => false,
+        'message' => (string) ($auth['message'] ?? 'Bitte E-Mail erneut verifizieren.'),
+        'auth_required' => $reason === 'auth_required',
+        'auth_expired' => $reason === 'auth_expired',
+    ], (int) ($auth['status_code'] ?? 401));
+}
+
 $app = Bootstrap::config('app');
 $limiter = new RateLimiter();
 $ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';

+ 15 - 0
api/submit.php

@@ -9,6 +9,7 @@ use App\Mail\Mailer;
 use App\Storage\FileUploadStore;
 use App\Storage\JsonStore;
 use App\Security\Csrf;
+use App\Security\FormAccess;
 use App\Security\RateLimiter;
 
 require dirname(__DIR__) . '/src/autoload.php';
@@ -51,6 +52,20 @@ if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
     Bootstrap::jsonResponse(['ok' => false, 'message' => 'Bitte gültige E-Mail eingeben.'], 422);
 }
 
+$activityRaw = $_POST['last_user_activity_at'] ?? null;
+$lastUserActivityAt = is_scalar($activityRaw) ? (int) $activityRaw : null;
+$formAccess = new FormAccess();
+$auth = $formAccess->assertVerifiedForEmail($email, $lastUserActivityAt);
+if (($auth['ok'] ?? false) !== true) {
+    $reason = (string) ($auth['reason'] ?? '');
+    Bootstrap::jsonResponse([
+        'ok' => false,
+        'message' => (string) ($auth['message'] ?? 'Bitte E-Mail erneut verifizieren.'),
+        'auth_required' => $reason === 'auth_required',
+        'auth_expired' => $reason === 'auth_expired',
+    ], (int) ($auth['status_code'] ?? 401));
+}
+
 $app = Bootstrap::config('app');
 $limiter = new RateLimiter();
 $ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';

+ 15 - 0
api/upload-preview.php

@@ -4,6 +4,7 @@ declare(strict_types=1);
 
 use App\App\Bootstrap;
 use App\Security\Csrf;
+use App\Security\FormAccess;
 use App\Security\RateLimiter;
 use App\Storage\JsonStore;
 
@@ -33,6 +34,20 @@ if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
     exit;
 }
 
+$activityRaw = $_GET['last_user_activity_at'] ?? null;
+$lastUserActivityAt = is_scalar($activityRaw) ? (int) $activityRaw : null;
+$formAccess = new FormAccess();
+$auth = $formAccess->assertVerifiedForEmail($email, $lastUserActivityAt);
+if (($auth['ok'] ?? false) !== true) {
+    $reason = (string) ($auth['reason'] ?? '');
+    Bootstrap::jsonResponse([
+        'ok' => false,
+        'message' => (string) ($auth['message'] ?? 'Bitte E-Mail erneut verifizieren.'),
+        'auth_required' => $reason === 'auth_required',
+        'auth_expired' => $reason === 'auth_expired',
+    ], (int) ($auth['status_code'] ?? 401));
+}
+
 $field = trim((string) ($_GET['field'] ?? ''));
 $index = (int) ($_GET['index'] ?? -1);
 if ($field === '' || $index < 0) {

+ 57 - 0
api/verify-otp.php

@@ -0,0 +1,57 @@
+<?php
+
+declare(strict_types=1);
+
+use App\App\Bootstrap;
+use App\Security\Csrf;
+use App\Security\FormAccess;
+use App\Security\RateLimiter;
+
+require dirname(__DIR__) . '/src/autoload.php';
+Bootstrap::init();
+
+if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
+    Bootstrap::jsonResponse(['ok' => false, 'message' => 'Method not allowed'], 405);
+}
+
+$csrf = $_POST['csrf'] ?? '';
+if (!Csrf::validate(is_string($csrf) ? $csrf : null)) {
+    Bootstrap::jsonResponse(['ok' => false, 'message' => 'Ungueltiges CSRF-Token.'], 419);
+}
+
+if (trim((string) ($_POST['website'] ?? '')) !== '') {
+    Bootstrap::jsonResponse(['ok' => false, 'message' => 'Anfrage blockiert.'], 400);
+}
+
+$email = strtolower(trim((string) ($_POST['email'] ?? '')));
+if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
+    Bootstrap::jsonResponse(['ok' => false, 'message' => 'Bitte gueltige E-Mail eingeben.'], 422);
+}
+
+$code = trim((string) ($_POST['otp_code'] ?? ''));
+
+$app = Bootstrap::config('app');
+$limiter = new RateLimiter();
+$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
+$rateKey = sprintf('otp-verify:%s:%s', $ip, $email);
+if (!$limiter->allow($rateKey, (int) $app['rate_limit']['requests'], (int) $app['rate_limit']['window_seconds'])) {
+    Bootstrap::jsonResponse(['ok' => false, 'message' => 'Zu viele Anfragen. Bitte spaeter erneut versuchen.'], 429);
+}
+
+$formAccess = new FormAccess();
+$result = $formAccess->verifyOtp($email, $code);
+if (($result['ok'] ?? false) !== true) {
+    $reason = (string) ($result['reason'] ?? '');
+    Bootstrap::jsonResponse([
+        'ok' => false,
+        'message' => (string) ($result['message'] ?? 'Code konnte nicht bestaetigt werden.'),
+        'auth_required' => in_array($reason, ['auth_required', 'expired', 'attempt_limit'], true),
+        'auth_expired' => false,
+        'attempts_left' => isset($result['attempts_left']) ? (int) $result['attempts_left'] : null,
+    ], (int) ($result['status_code'] ?? 422));
+}
+
+Bootstrap::jsonResponse([
+    'ok' => true,
+    'message' => 'E-Mail erfolgreich bestaetigt.',
+]);

+ 433 - 42
assets/js/form.js

@@ -1,15 +1,22 @@
 (function () {
   const EMAIL_STORAGE_KEY = 'ff_member_form_email_v1';
+  const AUTO_OTP_SESSION_PREFIX = 'ff_member_form_auto_otp_sent_v1:';
   const boot = window.APP_BOOT || { steps: [], csrf: '', contactEmail: '' };
   const baseUrl = String(boot.baseUrl || '').replace(/\/+$/, '');
   const schemaSteps = Array.isArray(boot.steps) ? boot.steps : [];
+  const verificationConfig = boot.verification && typeof boot.verification === 'object' ? boot.verification : {};
 
   const state = {
     email: '',
+    otpEmail: '',
+    isVerified: false,
+    lastUserActivityAt: Math.floor(Date.now() / 1000),
     currentStep: 1,
     totalSteps: schemaSteps.length,
     summaryStep: schemaSteps.length + 1,
     autosaveId: null,
+    otpCooldownId: null,
+    otpCooldownRemaining: 0,
     uploads: {},
     isSubmitting: false,
     summaryMissingCount: 0,
@@ -28,6 +35,13 @@
   const startEmailInput = document.getElementById('startEmail');
   const startEmailError = document.getElementById('startEmailError');
   const startSubmitBtn = document.getElementById('startSubmitBtn');
+  const otpSection = document.getElementById('otpSection');
+  const otpInfoText = document.getElementById('otpInfoText');
+  const startOtpInput = document.getElementById('startOtp');
+  const startOtpError = document.getElementById('startOtpError');
+  const verifyOtpBtn = document.getElementById('verifyOtpBtn');
+  const resendOtpBtn = document.getElementById('resendOtpBtn');
+  const otpCooldownMessage = document.getElementById('otpCooldownMessage');
   const resetDataBtn = document.getElementById('resetDataBtn');
   const compactStatusBox = document.getElementById('compactStatusBox');
   const statusEmailValue = document.getElementById('statusEmailValue');
@@ -136,6 +150,13 @@
     startEmailError.textContent = text || '';
   }
 
+  function setStartOtpError(text) {
+    if (!startOtpError) {
+      return;
+    }
+    startOtpError.textContent = text || '';
+  }
+
   function setDisclaimerError(text) {
     if (!disclaimerReadError) {
       return;
@@ -169,6 +190,10 @@
     return (email || '').trim().toLowerCase();
   }
 
+  function normalizeOtpCode(code) {
+    return String(code || '').replace(/[^\d]/g, '').slice(0, 6);
+  }
+
   function isValidEmail(email) {
     return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
   }
@@ -197,6 +222,26 @@
     }
   }
 
+  function autoOtpSessionKey(email) {
+    return AUTO_OTP_SESSION_PREFIX + normalizeEmail(email);
+  }
+
+  function hasAutoOtpSessionFlag(email) {
+    try {
+      return sessionStorage.getItem(autoOtpSessionKey(email)) === '1';
+    } catch (_err) {
+      return false;
+    }
+  }
+
+  function markAutoOtpSessionFlag(email) {
+    try {
+      sessionStorage.setItem(autoOtpSessionKey(email), '1');
+    } catch (_err) {
+      // ignore
+    }
+  }
+
   function updateDisclaimerAcceptanceState() {
     if (!acceptDisclaimerBtn || !disclaimerReadCheckbox) {
       return;
@@ -225,6 +270,9 @@
     startIntroText.classList.add('hidden');
     startEmailField.classList.add('hidden');
     startActions.classList.add('hidden');
+    if (otpSection) {
+      otpSection.classList.add('hidden');
+    }
   }
 
   function leaveCompactStatus() {
@@ -235,11 +283,91 @@
     startActions.classList.remove('hidden');
     statusEmailValue.textContent = '-';
     setDraftStatus('Noch nicht gespeichert', false);
-    setResetActionVisible(true);
+    setResetActionVisible(false);
+  }
+
+  function showOtpSection(email, message) {
+    state.otpEmail = normalizeEmail(email);
+    if (otpSection) {
+      otpSection.classList.remove('hidden');
+    }
+    if (otpInfoText) {
+      otpInfoText.textContent = message || ('Code wurde an ' + state.otpEmail + ' gesendet.');
+      otpInfoText.classList.remove('error-text');
+    }
+    if (startOtpInput) {
+      startOtpInput.value = '';
+      startOtpInput.focus();
+    }
+    setStartOtpError('');
+  }
+
+  function hideOtpSection() {
+    if (otpSection) {
+      otpSection.classList.add('hidden');
+    }
+    if (otpInfoText) {
+      otpInfoText.textContent = '';
+    }
+    setStartOtpError('');
+    clearOtpCooldown();
+    state.otpEmail = '';
+  }
+
+  function clearOtpCooldown() {
+    if (state.otpCooldownId) {
+      clearInterval(state.otpCooldownId);
+      state.otpCooldownId = null;
+    }
+    state.otpCooldownRemaining = 0;
+    if (resendOtpBtn) {
+      resendOtpBtn.disabled = false;
+    }
+    if (otpCooldownMessage) {
+      otpCooldownMessage.textContent = '';
+    }
+  }
+
+  function setOtpCooldown(seconds) {
+    clearOtpCooldown();
+    const total = Math.max(0, Number(seconds || 0));
+    state.otpCooldownRemaining = total;
+    if (total <= 0) {
+      return;
+    }
+
+    if (resendOtpBtn) {
+      resendOtpBtn.disabled = true;
+    }
+
+    const update = () => {
+      if (!otpCooldownMessage) {
+        return;
+      }
+
+      if (state.otpCooldownRemaining <= 0) {
+        otpCooldownMessage.textContent = '';
+        if (resendOtpBtn) {
+          resendOtpBtn.disabled = false;
+        }
+        if (state.otpCooldownId) {
+          clearInterval(state.otpCooldownId);
+          state.otpCooldownId = null;
+        }
+        return;
+      }
+
+      otpCooldownMessage.textContent = 'Neuer Code in ' + String(state.otpCooldownRemaining) + 's verfuegbar.';
+      state.otpCooldownRemaining -= 1;
+    };
+
+    update();
+    state.otpCooldownId = setInterval(update, 1000);
   }
 
   function lockEmail(email) {
     state.email = email;
+    state.isVerified = true;
     applicationEmail.value = email;
     startEmailInput.value = email;
     startEmailInput.readOnly = true;
@@ -249,8 +377,10 @@
     enterCompactStatus(email);
   }
 
-  function unlockEmail(clearInput) {
+  function unlockEmail(clearInput, forgetEmail) {
+    const shouldForget = forgetEmail !== false;
     state.email = '';
+    state.isVerified = false;
     applicationEmail.value = '';
     startEmailInput.readOnly = false;
     startEmailInput.removeAttribute('aria-readonly');
@@ -259,8 +389,12 @@
       setStartEmailError('');
     }
     updateStartEmailRequiredMarker();
-    forgetRememberedEmail();
+    if (shouldForget) {
+      forgetRememberedEmail();
+    }
     leaveCompactStatus();
+    hideOtpSection();
+    setDisclaimerError('');
   }
 
   function stopAutosave() {
@@ -270,16 +404,23 @@
     }
   }
 
+  function markUserActivity() {
+    state.lastUserActivityAt = Math.floor(Date.now() / 1000);
+  }
+
   function startAutosave() {
     stopAutosave();
     state.autosaveId = setInterval(async () => {
-      if (!state.email || wizardSection.classList.contains('hidden')) {
+      if (!state.email || !state.isVerified || wizardSection.classList.contains('hidden')) {
         return;
       }
       try {
         await saveDraft(false);
-      } catch (_err) {
-        // visible on next manual action
+      } catch (err) {
+        const payload = (err && err.payload) || {};
+        if (isAuthFailurePayload(payload)) {
+          handleProtectedAuthFailure(payload);
+        }
       }
     }, 15000);
   }
@@ -1078,6 +1219,7 @@
     params.set('email', state.email);
     params.set('field', fieldKey);
     params.set('index', String(index));
+    params.set('last_user_activity_at', String(state.lastUserActivityAt));
     return appUrl('api/upload-preview.php') + '?' + params.toString();
   }
 
@@ -1088,6 +1230,7 @@
     fd.append('website', '');
     fd.append('field', fieldKey);
     fd.append('index', String(index));
+    fd.append('last_user_activity_at', String(state.lastUserActivityAt));
 
     const response = await postForm(appUrl('api/delete-upload.php'), fd);
     renderUploadInfo(response.uploads || {});
@@ -1133,6 +1276,10 @@
         previewLink.target = '_blank';
         previewLink.rel = 'noopener noreferrer';
         previewLink.textContent = 'Vorschau';
+        previewLink.addEventListener('click', () => {
+          markUserActivity();
+          previewLink.href = buildUploadPreviewUrl(field, index);
+        });
         actions.appendChild(previewLink);
 
         const deleteBtn = document.createElement('button');
@@ -1140,6 +1287,7 @@
         deleteBtn.className = 'upload-item-btn upload-item-btn-danger';
         deleteBtn.textContent = 'Löschen';
         deleteBtn.addEventListener('click', async () => {
+          markUserActivity();
           const confirmed = window.confirm('Diesen Upload wirklich löschen?');
           if (!confirmed) {
             return;
@@ -1150,8 +1298,7 @@
             await deleteUploadedFile(field, index);
             setFeedback('Upload gelöscht.', false);
           } catch (err) {
-            const msg = (err.payload && err.payload.message) || err.message || 'Löschen fehlgeschlagen.';
-            setFeedback(msg, true);
+            handleProtectedError(err, 'Loeschen fehlgeschlagen.', 'wizard');
           } finally {
             deleteBtn.disabled = false;
           }
@@ -1201,7 +1348,12 @@
         renderSummary();
       }
     } catch (err) {
-      const msg = (err.payload && err.payload.message) || err.message || 'Upload fehlgeschlagen.';
+      const wasAuth = handleProtectedError(err, 'Upload fehlgeschlagen.', 'wizard');
+      if (wasAuth) {
+        return;
+      }
+      const payload = (err && err.payload) || {};
+      const msg = payload.message || err.message || 'Upload fehlgeschlagen.';
       setDraftStatus('Upload fehlgeschlagen', true);
       setFeedback(msg, true);
     }
@@ -1255,7 +1407,13 @@
       headers: { 'X-Requested-With': 'XMLHttpRequest' },
     });
 
-    const payload = await response.json();
+    let payload = {};
+    try {
+      payload = await response.json();
+    } catch (_err) {
+      payload = {};
+    }
+
     if (!response.ok || payload.ok === false) {
       const err = new Error(payload.message || 'Anfrage fehlgeschlagen');
       err.payload = payload;
@@ -1265,12 +1423,82 @@
     return payload;
   }
 
+  function isAuthFailurePayload(payload) {
+    return Boolean(payload && (payload.auth_required || payload.auth_expired));
+  }
+
+  function inactivityInfoText() {
+    const inactivitySeconds = Math.max(60, Number(verificationConfig.inactivity_seconds || 3600));
+    const inactivityMinutes = Math.round(inactivitySeconds / 60);
+    if (inactivityMinutes % 60 === 0) {
+      const hours = inactivityMinutes / 60;
+      return 'nach ' + String(hours) + ' Stunde' + (hours === 1 ? '' : 'n') + ' Inaktivitaet';
+    }
+    return 'nach ' + String(inactivityMinutes) + ' Minuten Inaktivitaet';
+  }
+
+  function handleProtectedAuthFailure(payload) {
+    const email = normalizeEmail(state.email || startEmailInput.value || '');
+    stopAutosave();
+    wizardSection.classList.add('hidden');
+    disclaimerSection.classList.add('hidden');
+    clearWizardData();
+    unlockEmail(false, false);
+    setResetActionVisible(false);
+
+    if (email !== '') {
+      startEmailInput.value = email;
+      updateStartEmailRequiredMarker();
+    }
+
+    const isExpired = Boolean(payload && payload.auth_expired);
+    const defaultMessage = isExpired
+      ? 'Ihre Sitzung ist ' + inactivityInfoText() + ' abgelaufen. Bitte erneut verifizieren.'
+      : 'Bitte zuerst E-Mail und Sicherheitscode bestaetigen.';
+
+    setFeedback((payload && payload.message) || defaultMessage, true, 'start');
+    setDraftStatus(isExpired ? 'Sitzung abgelaufen' : 'Verifizierung erforderlich', true);
+    hideOtpSection();
+    startEmailInput.focus();
+  }
+
+  function handleProtectedError(err, fallbackMessage, scope) {
+    const payload = err && err.payload ? err.payload : {};
+    if (isAuthFailurePayload(payload)) {
+      handleProtectedAuthFailure(payload);
+      return true;
+    }
+
+    const msg = payload.message || err.message || fallbackMessage;
+    setFeedback(msg, true, scope || 'wizard');
+    return false;
+  }
+
+  async function requestOtpCode(email, autoStart) {
+    const fd = new FormData();
+    fd.append('csrf', boot.csrf);
+    fd.append('website', '');
+    fd.append('email', email);
+    fd.append('auto_start', autoStart ? '1' : '0');
+    return postForm(appUrl('api/request-otp.php'), fd);
+  }
+
+  async function verifyOtpCode(email, code) {
+    const fd = new FormData();
+    fd.append('csrf', boot.csrf);
+    fd.append('website', '');
+    fd.append('email', email);
+    fd.append('otp_code', code);
+    return postForm(appUrl('api/verify-otp.php'), fd);
+  }
+
   function collectPayload(includeFiles) {
     const fd = new FormData();
     fd.append('csrf', boot.csrf);
     fd.append('email', state.email);
     fd.append('step', String(Math.min(state.currentStep, state.totalSteps)));
     fd.append('website', '');
+    fd.append('last_user_activity_at', String(state.lastUserActivityAt));
 
     Array.from(applicationForm.elements).forEach((el) => {
       if (!el.name) {
@@ -1309,6 +1537,7 @@
     fd.append('csrf', boot.csrf);
     fd.append('email', email);
     fd.append('website', '');
+    fd.append('last_user_activity_at', String(state.lastUserActivityAt));
     return postForm(appUrl('api/load-draft.php'), fd);
   }
 
@@ -1317,6 +1546,7 @@
     fd.append('csrf', boot.csrf);
     fd.append('email', email);
     fd.append('website', '');
+    fd.append('last_user_activity_at', String(state.lastUserActivityAt));
     return postForm(appUrl('api/reset.php'), fd);
   }
 
@@ -1412,12 +1642,29 @@
     return false;
   }
 
-  async function startProcess(rawEmail) {
+  function validateOtpCode(showError) {
+    const code = normalizeOtpCode(startOtpInput.value || '');
+    startOtpInput.value = code;
+    if (/^\d{6}$/.test(code)) {
+      setStartOtpError('');
+      return true;
+    }
+
+    if (showError) {
+      setStartOtpError('Bitte einen 6-stelligen Code eingeben.');
+    }
+
+    return false;
+  }
+
+  async function requestOtpFlow(rawEmail, options) {
+    const opts = options || {};
+    const autoStart = Boolean(opts.autoStart);
     const email = normalizeEmail(rawEmail);
     if (!isValidEmail(email)) {
       const message = 'Bitte eine gültige E-Mail-Adresse eingeben.';
       setStartEmailError(message);
-      setFeedback(message, true);
+      setFeedback(message, true, 'start');
       startEmailInput.focus();
       return;
     }
@@ -1430,14 +1677,109 @@
     if (startSubmitBtn) {
       startSubmitBtn.disabled = true;
     }
+    if (resendOtpBtn) {
+      resendOtpBtn.disabled = true;
+    }
+
+    try {
+      const result = await requestOtpCode(email, autoStart);
+      if (autoStart) {
+        markAutoOtpSessionFlag(email);
+      }
+
+      if (result.auto_skipped) {
+        showOtpSection(email, 'Bitte Sicherheitscode eingeben oder einen neuen Code anfordern.');
+        setFeedback('', false, 'start');
+      } else {
+        showOtpSection(email, result.message || ('Sicherheitscode wurde an ' + email + ' gesendet.'));
+        setFeedback(result.message || 'Sicherheitscode wurde versendet.', false, 'start');
+        setOtpCooldown(Number(result.resend_available_in || 0));
+      }
+    } catch (err) {
+      const payload = (err && err.payload) || {};
+      const retryAfter = Number(payload.retry_after || 0);
+      if (retryAfter > 0) {
+        setOtpCooldown(retryAfter);
+      }
+      setFeedback(payload.message || err.message || 'Code konnte nicht gesendet werden.', true, 'start');
+    } finally {
+      if (startSubmitBtn) {
+        startSubmitBtn.disabled = false;
+      }
+      if (resendOtpBtn && state.otpCooldownRemaining <= 0) {
+        resendOtpBtn.disabled = false;
+      }
+    }
+  }
+
+  async function verifyOtpFlow() {
+    const email = normalizeEmail(startEmailInput.value || '');
+    if (!isValidEmail(email)) {
+      setStartEmailError('Bitte eine gültige E-Mail-Adresse eingeben.');
+      startEmailInput.focus();
+      return;
+    }
+
+    if (!validateOtpCode(true)) {
+      startOtpInput.focus();
+      return;
+    }
+
+    if (verifyOtpBtn) {
+      verifyOtpBtn.disabled = true;
+    }
+    if (resendOtpBtn) {
+      resendOtpBtn.disabled = true;
+    }
 
     try {
-      const result = await loadDraft(email);
+      const result = await verifyOtpCode(email, normalizeOtpCode(startOtpInput.value || ''));
       lockEmail(email);
       setResetActionVisible(true);
+      hideOtpSection();
+      setFeedback(result.message || 'E-Mail erfolgreich bestaetigt.', false, 'start');
+      markUserActivity();
+      disclaimerSection.classList.remove('hidden');
+      wizardSection.classList.add('hidden');
+      if (disclaimerReadCheckbox) {
+        disclaimerReadCheckbox.checked = false;
+      }
+      updateDisclaimerAcceptanceState();
+      disclaimerSection.scrollIntoView({ behavior: 'smooth', block: 'start' });
+    } catch (err) {
+      const payload = (err && err.payload) || {};
+      const attemptsLeft = Number(payload.attempts_left);
+      if (Number.isFinite(attemptsLeft) && attemptsLeft >= 0 && attemptsLeft < 5) {
+        setStartOtpError((payload.message || 'Code ungueltig.') + ' Verbleibende Versuche: ' + String(attemptsLeft));
+      } else {
+        setStartOtpError(payload.message || err.message || 'Code konnte nicht bestaetigt werden.');
+      }
+      setFeedback(payload.message || err.message || 'Code konnte nicht bestaetigt werden.', true, 'start');
+      startOtpInput.focus();
+    } finally {
+      if (verifyOtpBtn) {
+        verifyOtpBtn.disabled = false;
+      }
+      if (resendOtpBtn && state.otpCooldownRemaining <= 0) {
+        resendOtpBtn.disabled = false;
+      }
+    }
+  }
+
+  async function openWizardForVerifiedEmail() {
+    if (!state.email || !state.isVerified) {
+      setFeedback('Bitte zuerst E-Mail und Sicherheitscode bestaetigen.', true, 'start');
+      return;
+    }
+
+    try {
+      markUserActivity();
+      const result = await loadDraft(state.email);
+      setResetActionVisible(true);
 
       if (result.already_submitted) {
         wizardSection.classList.add('hidden');
+        disclaimerSection.classList.add('hidden');
         setDraftStatus('Antrag bereits abgeschlossen', false);
         setFeedback(
           result.message || 'Für diese E-Mail liegt bereits ein abgeschlossener Antrag vor.',
@@ -1449,6 +1791,7 @@
         return;
       }
 
+      disclaimerSection.classList.add('hidden');
       wizardSection.classList.remove('hidden');
       fillFormData(result.data || {});
       renderUploadInfo(result.uploads || {});
@@ -1464,24 +1807,36 @@
       } else {
         setDraftStatus('Neuer Entwurf gestartet', false);
       }
-      setFeedback('', false);
+      setFeedback('', false, 'wizard');
     } catch (err) {
-      const msg = (err.payload && err.payload.message) || err.message || 'Laden fehlgeschlagen.';
-      setFeedback(msg, true);
-    } finally {
-      if (startSubmitBtn) {
-        startSubmitBtn.disabled = false;
+      const handled = handleProtectedError(err, 'Laden fehlgeschlagen.', 'start');
+      if (!handled) {
+        // keep default message already set
       }
     }
   }
 
+  function initAutoOtpForRememberedEmail() {
+    const rememberedEmail = normalizeEmail(getRememberedEmail());
+    if (rememberedEmail === '' || !isValidEmail(rememberedEmail)) {
+      return;
+    }
+
+    startEmailInput.value = rememberedEmail;
+    updateStartEmailRequiredMarker();
+
+    if (!hasAutoOtpSessionFlag(rememberedEmail)) {
+      requestOtpFlow(rememberedEmail, { autoStart: true });
+    }
+  }
+
   startForm.addEventListener('submit', async (event) => {
     event.preventDefault();
     if (!validateStartEmail(true)) {
       startEmailInput.focus();
       return;
     }
-    await startProcess(startEmailInput.value || '');
+    await requestOtpFlow(startEmailInput.value || '', { autoStart: false });
   });
 
   startEmailInput.addEventListener('input', () => {
@@ -1494,6 +1849,9 @@
       updateStartEmailRequiredMarker();
       return;
     }
+    if (state.otpEmail !== '' && state.otpEmail !== normalizeEmail(startEmailInput.value || '')) {
+      hideOtpSection();
+    }
     validateStartEmail(false);
   });
 
@@ -1520,16 +1878,17 @@
     }
 
     try {
+      markUserActivity();
       await resetSavedData(state.email);
       stopAutosave();
       wizardSection.classList.add('hidden');
+      disclaimerSection.classList.add('hidden');
       clearWizardData();
       unlockEmail(true);
       setFeedback('Alle gespeicherten Daten wurden gelöscht. Sie können neu starten.', false);
       startEmailInput.focus();
     } catch (err) {
-      const msg = (err.payload && err.payload.message) || err.message || 'Löschen fehlgeschlagen.';
-      setFeedback(msg, true);
+      handleProtectedError(err, 'Loeschen fehlgeschlagen.', 'start');
     }
   });
 
@@ -1539,6 +1898,7 @@
     }
 
     try {
+      markUserActivity();
       if (state.currentStep <= state.totalSteps) {
         await saveDraft(false);
       }
@@ -1546,8 +1906,7 @@
       updateProgress();
       scrollWizardToTop();
     } catch (err) {
-      const msg = (err.payload && err.payload.message) || err.message;
-      setFeedback(msg, true);
+      handleProtectedError(err, 'Speichern fehlgeschlagen.', 'wizard');
     }
   });
 
@@ -1557,13 +1916,13 @@
     }
 
     try {
+      markUserActivity();
       await saveDraft(false);
       state.currentStep += 1;
       updateProgress();
       scrollWizardToTop();
     } catch (err) {
-      const msg = (err.payload && err.payload.message) || err.message;
-      setFeedback(msg, true);
+      handleProtectedError(err, 'Speichern fehlgeschlagen.', 'wizard');
     }
   });
 
@@ -1579,9 +1938,14 @@
     }
 
     try {
+      markUserActivity();
       await submitApplication();
     } catch (err) {
       const payload = err.payload || {};
+      if (isAuthFailurePayload(payload)) {
+        handleProtectedAuthFailure(payload);
+        return;
+      }
       if (payload.errors) {
         showErrors(payload.errors);
       }
@@ -1595,36 +1959,62 @@
     }
   });
 
-  function initializeAfterDisclaimer() {
-    disclaimerSection.classList.add('hidden');
-    startSection.classList.remove('hidden');
-
-    const rememberedEmail = normalizeEmail(getRememberedEmail());
-    if (rememberedEmail !== '' && isValidEmail(rememberedEmail)) {
-      startEmailInput.value = rememberedEmail;
-      setFeedback('', false);
-      startProcess(rememberedEmail);
-    }
-  }
-
   if (disclaimerReadCheckbox) {
     disclaimerReadCheckbox.addEventListener('change', updateDisclaimerAcceptanceState);
   }
 
   if (acceptDisclaimerBtn) {
-    acceptDisclaimerBtn.addEventListener('click', () => {
+    acceptDisclaimerBtn.addEventListener('click', async () => {
       if (!disclaimerReadCheckbox || !disclaimerReadCheckbox.checked) {
         setDisclaimerError('Bitte lesen und bestätigen Sie den Hinweis.');
         return;
       }
       setDisclaimerError('');
-      initializeAfterDisclaimer();
+      markUserActivity();
+      await openWizardForVerifiedEmail();
+    });
+  }
+
+  if (verifyOtpBtn) {
+    verifyOtpBtn.addEventListener('click', async () => {
+      await verifyOtpFlow();
+    });
+  }
+
+  if (resendOtpBtn) {
+    resendOtpBtn.addEventListener('click', async () => {
+      await requestOtpFlow(startEmailInput.value || '', { autoStart: false });
     });
   }
 
+  if (startOtpInput) {
+    startOtpInput.addEventListener('input', () => {
+      startOtpInput.value = normalizeOtpCode(startOtpInput.value || '');
+      setStartOtpError('');
+    });
+
+    startOtpInput.addEventListener('keydown', async (event) => {
+      if (event.key !== 'Enter') {
+        return;
+      }
+      event.preventDefault();
+      await verifyOtpFlow();
+    });
+  }
+
+  ['input', 'change', 'click', 'keydown'].forEach((eventName) => {
+    applicationForm.addEventListener(eventName, () => {
+      if (wizardSection.classList.contains('hidden')) {
+        return;
+      }
+      markUserActivity();
+    });
+  });
+
   updateDisclaimerAcceptanceState();
-  disclaimerSection.classList.remove('hidden');
-  startSection.classList.add('hidden');
+  disclaimerSection.classList.add('hidden');
+  startSection.classList.remove('hidden');
+  wizardSection.classList.add('hidden');
 
   initTableFields();
   initUploadControls();
@@ -1633,4 +2023,5 @@
   refreshRequiredMarkers();
   updateStartEmailRequiredMarker();
   updateProgress();
+  initAutoOtpForRememberedEmail();
 })();

+ 7 - 0
config/app.sample.php

@@ -36,6 +36,13 @@ return [
         'requests' => 30,
         'window_seconds' => 300,
     ],
+    'verification' => [
+        'required' => true,
+        'inactivity_seconds' => 3600,
+        'otp_ttl_seconds' => 600,
+        'otp_attempt_limit' => 5,
+        'resend_cooldown_seconds' => 60,
+    ],
     'admin' => [
         // Feste Zugangsdaten als Tabelle (hardcoded).
         // Hash mit: php -r "echo password_hash('DEIN-PASSWORT', PASSWORD_DEFAULT), PHP_EOL;"

+ 5 - 0
config/mail.sample.php

@@ -11,5 +11,10 @@ return [
     'subjects' => [
         'admin' => 'Neuer Mitgliedsantrag',
         'applicant' => 'Bestaetigung deines Mitgliedsantrags',
+        'otp' => 'Ihr Sicherheitscode fuer den Mitgliedsantrag',
+    ],
+    'otp' => [
+        'text_template' => "Ihr Sicherheitscode lautet: {{code}}\nDer Code ist {{ttl_minutes}} Minuten gueltig.",
+        'html_template' => '<p>Ihr Sicherheitscode lautet: <strong>{{code}}</strong></p><p>Der Code ist {{ttl_minutes}} Minuten gueltig.</p>',
     ],
 ];

+ 42 - 28
index.php

@@ -215,7 +215,46 @@ function renderField(array $field, string $addressDisclaimerText): void
 <main class="container">
     <h1>Digitaler Mitgliedsantrag Feuerwehrverein</h1>
 
-    <section id="disclaimerSection" class="card">
+    <section id="startSection" class="card">
+        <h2>Sicherheitspruefung</h2>
+        <p id="startIntroText">Bitte E-Mail eingeben. Danach senden wir einen 6-stelligen Sicherheitscode.</p>
+        <form id="startForm" novalidate>
+            <input type="hidden" name="csrf" value="<?= htmlspecialchars($csrf) ?>">
+            <div class="hp-field" aria-hidden="true">
+                <label for="website">Website</label>
+                <input id="website" type="text" name="website" autocomplete="off" tabindex="-1">
+            </div>
+            <div class="field" id="startEmailField">
+                <label for="startEmail">E-Mail <span class="required-mark required-mark-field-start" aria-hidden="true">* Pflichtfeld</span></label>
+                <input id="startEmail" type="email" name="email" required inputmode="email" autocomplete="email">
+                <div id="startEmailError" class="error"></div>
+            </div>
+            <div class="inline-actions" id="startActions">
+                <button id="startSubmitBtn" type="submit" class="btn">Code senden</button>
+            </div>
+            <section id="otpSection" class="hidden" aria-live="polite">
+                <p id="otpInfoText" class="status-text"></p>
+                <div class="field" id="startOtpField">
+                    <label for="startOtp">Sicherheitscode <span class="required-mark required-mark-field-start" aria-hidden="true">* Pflichtfeld</span></label>
+                    <input id="startOtp" type="text" inputmode="numeric" autocomplete="one-time-code" maxlength="6" pattern="[0-9]{6}">
+                    <div id="startOtpError" class="error"></div>
+                </div>
+                <div class="inline-actions" id="otpActions">
+                    <button id="verifyOtpBtn" type="button" class="btn">Code bestaetigen</button>
+                    <button id="resendOtpBtn" type="button" class="btn btn-secondary">Code erneut senden</button>
+                </div>
+                <p id="otpCooldownMessage" class="status-text"></p>
+            </section>
+            <div id="compactStatusBox" class="compact-status hidden">
+                <p><strong>E-Mail:</strong> <span id="statusEmailValue">-</span></p>
+                <p><strong>Speicherstatus:</strong> <span id="draftStatusValue">Noch nicht gespeichert</span></p>
+                <button id="resetDataBtn" type="button" class="btn btn-small">Gespeicherte Daten löschen und neu starten</button>
+            </div>
+            <p id="startFeedbackMessage" class="status-text" role="status" aria-live="polite"></p>
+        </form>
+    </section>
+
+    <section id="disclaimerSection" class="card hidden">
         <h2><?= htmlspecialchars($disclaimerTitle) ?></h2>
         <p class="disclaimer-text"><?= nl2br(htmlspecialchars($disclaimerText)) ?></p>
         <div class="field disclaimer-ack-field">
@@ -266,39 +305,14 @@ function renderField(array $field, string $addressDisclaimerText): void
             <p id="feedbackMessage" class="status-text" role="status" aria-live="polite"></p>
         </form>
     </section>
-
-    <section id="startSection" class="card hidden">
-        <h2>Status</h2>
-        <p id="startIntroText">Bitte E-Mail eingeben. Bestehende Entwürfe werden automatisch geladen.</p>
-        <form id="startForm" novalidate>
-            <input type="hidden" name="csrf" value="<?= htmlspecialchars($csrf) ?>">
-            <div class="hp-field" aria-hidden="true">
-                <label for="website">Website</label>
-                <input id="website" type="text" name="website" autocomplete="off" tabindex="-1">
-            </div>
-            <div class="field" id="startEmailField">
-                <label for="startEmail">E-Mail <span class="required-mark required-mark-field-start" aria-hidden="true">* Pflichtfeld</span></label>
-                <input id="startEmail" type="email" name="email" required inputmode="email" autocomplete="email">
-                <div id="startEmailError" class="error"></div>
-            </div>
-            <div class="inline-actions" id="startActions">
-                <button id="startSubmitBtn" type="submit" class="btn">Formular laden</button>
-            </div>
-            <div id="compactStatusBox" class="compact-status hidden">
-                <p><strong>E-Mail:</strong> <span id="statusEmailValue">-</span></p>
-                <p><strong>Speicherstatus:</strong> <span id="draftStatusValue">Noch nicht gespeichert</span></p>
-                <button id="resetDataBtn" type="button" class="btn btn-small">Gespeicherte Daten löschen und neu starten</button>
-            </div>
-            <p id="startFeedbackMessage" class="status-text" role="status" aria-live="polite"></p>
-        </form>
-    </section>
 </main>
 <script>
 window.APP_BOOT = {
     steps: <?= json_encode($steps, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>,
     csrf: <?= json_encode($csrf, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>,
     contactEmail: <?= json_encode((string) ($app['contact_email'] ?? ''), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>,
-    baseUrl: <?= json_encode($baseUrl, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>
+    baseUrl: <?= json_encode($baseUrl, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>,
+    verification: <?= json_encode((array) ($app['verification'] ?? []), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>
 };
 </script>
 <script src="<?= htmlspecialchars(Bootstrap::url('assets/js/form.js')) ?>"></script>

+ 74 - 0
src/mail/mailer.php

@@ -11,17 +11,50 @@ final class Mailer
 {
     /** @var array<string, mixed> */
     private array $mailConfig;
+    /** @var array<string, mixed> */
+    private array $appConfig;
     private SubmissionFormatter $formatter;
     private PdfGenerator $pdfGenerator;
 
     public function __construct()
     {
         $this->mailConfig = Bootstrap::config('mail');
+        $this->appConfig = Bootstrap::config('app');
         $schema = new FormSchema();
         $this->formatter = new SubmissionFormatter($schema);
         $this->pdfGenerator = new PdfGenerator($this->formatter, $schema);
     }
 
+    public function sendOtpMail(string $email, string $code, int $ttlSeconds): bool
+    {
+        $email = strtolower(trim($email));
+        if ($email === '') {
+            return false;
+        }
+
+        $subject = (string) ($this->mailConfig['subjects']['otp'] ?? 'Ihr Sicherheitscode');
+        $textBody = $this->renderOtpText($code, $ttlSeconds);
+        $htmlBody = $this->renderOtpHtml($code, $ttlSeconds);
+
+        try {
+            $mail = $this->createMailBuilder();
+            $mail->setTo($email)
+                ->setSubject($subject)
+                ->setTextBody($textBody)
+                ->setHtmlBody($htmlBody);
+
+            if (!$mail->send()) {
+                Bootstrap::log('mail', 'Versand OTP fehlgeschlagen: ' . $email . ' - ' . $mail->getErrorInfo());
+                return false;
+            }
+        } catch (\Throwable $e) {
+            Bootstrap::log('mail', 'Versand OTP fehlgeschlagen: ' . $email . ' - ' . $e->getMessage());
+            return false;
+        }
+
+        return true;
+    }
+
     /** @param array<string, mixed> $submission */
     public function sendSubmissionMails(array $submission): void
     {
@@ -287,4 +320,45 @@ final class Mailer
     {
         return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
     }
+
+    private function renderOtpText(string $code, int $ttlSeconds): string
+    {
+        $configured = (string) ($this->mailConfig['otp']['text_template'] ?? '');
+        $template = trim($configured);
+        if ($template === '') {
+            $template = "Ihr Sicherheitscode lautet: {{code}}\nDer Code ist {{ttl_minutes}} Minuten gueltig.";
+        }
+
+        return $this->replaceOtpTemplateVars($template, $code, $ttlSeconds, false);
+    }
+
+    private function renderOtpHtml(string $code, int $ttlSeconds): string
+    {
+        $configured = (string) ($this->mailConfig['otp']['html_template'] ?? '');
+        $template = trim($configured);
+        if ($template === '') {
+            $template = '<p>Ihr Sicherheitscode lautet: <strong>{{code}}</strong></p><p>Der Code ist {{ttl_minutes}} Minuten gueltig.</p>';
+        }
+
+        return $this->replaceOtpTemplateVars($template, $code, $ttlSeconds, true);
+    }
+
+    private function replaceOtpTemplateVars(string $template, string $code, int $ttlSeconds, bool $htmlContext): string
+    {
+        $minutes = (string) max(1, (int) ceil($ttlSeconds / 60));
+        $projectName = (string) ($this->appConfig['project_name'] ?? 'Mitgliedsantrag');
+        $safeCode = trim($code);
+        $safeProjectName = trim($projectName);
+        if ($htmlContext) {
+            $safeCode = $this->esc($safeCode);
+            $safeProjectName = $this->esc($safeProjectName);
+        }
+
+        return strtr($template, [
+            '{{code}}' => $safeCode,
+            '{{ttl_seconds}}' => (string) max(1, $ttlSeconds),
+            '{{ttl_minutes}}' => $minutes,
+            '{{project_name}}' => $safeProjectName,
+        ]);
+    }
 }

+ 365 - 0
src/security/formaccess.php

@@ -0,0 +1,365 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Security;
+
+use App\App\Bootstrap;
+
+final class FormAccess
+{
+    private const SESSION_KEY = '_form_access';
+
+    private bool $required;
+    private int $inactivitySeconds;
+    private int $otpTtlSeconds;
+    private int $otpAttemptLimit;
+    private int $resendCooldownSeconds;
+
+    public function __construct()
+    {
+        $app = Bootstrap::config('app');
+        $verification = is_array($app['verification'] ?? null) ? $app['verification'] : [];
+
+        $this->required = (bool) ($verification['required'] ?? true);
+        $this->inactivitySeconds = max(60, (int) ($verification['inactivity_seconds'] ?? 3600));
+        $this->otpTtlSeconds = max(60, (int) ($verification['otp_ttl_seconds'] ?? 600));
+        $this->otpAttemptLimit = max(1, (int) ($verification['otp_attempt_limit'] ?? 5));
+        $this->resendCooldownSeconds = max(5, (int) ($verification['resend_cooldown_seconds'] ?? 60));
+    }
+
+    public function isRequired(): bool
+    {
+        return $this->required;
+    }
+
+    public function inactivitySeconds(): int
+    {
+        return $this->inactivitySeconds;
+    }
+
+    public function otpTtlSeconds(): int
+    {
+        return $this->otpTtlSeconds;
+    }
+
+    public function otpAttemptLimit(): int
+    {
+        return $this->otpAttemptLimit;
+    }
+
+    public function resendCooldownSeconds(): int
+    {
+        return $this->resendCooldownSeconds;
+    }
+
+    /**
+     * @return array{ok: bool, reason?: string, status_code?: int, message?: string, retry_after?: int, cooldown_seconds?: int, expires_in?: int, code?: string, auto_skipped?: bool}
+     */
+    public function requestOtp(string $email, bool $autoStart = false): array
+    {
+        if (!$this->required) {
+            return [
+                'ok' => true,
+                'auto_skipped' => true,
+            ];
+        }
+
+        $email = $this->normalizeEmail($email);
+        $now = time();
+
+        if ($email === '') {
+            return [
+                'ok' => false,
+                'reason' => 'invalid_email',
+                'status_code' => 422,
+                'message' => 'Bitte gueltige E-Mail eingeben.',
+            ];
+        }
+
+        if ($autoStart && $this->hasAutoOtpSentForEmail($email)) {
+            return [
+                'ok' => true,
+                'auto_skipped' => true,
+            ];
+        }
+
+        $state =& $this->state();
+        $pending = $this->pendingState();
+        $pendingEmail = $this->normalizeEmail((string) ($pending['email'] ?? ''));
+        $cooldownUntil = (int) ($pending['cooldown_until'] ?? 0);
+        if ($pendingEmail === $email && $cooldownUntil > $now) {
+            return [
+                'ok' => false,
+                'reason' => 'cooldown',
+                'status_code' => 429,
+                'retry_after' => $cooldownUntil - $now,
+                'message' => 'Bitte kurz warten, bevor ein neuer Code gesendet wird.',
+            ];
+        }
+
+        $code = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);
+        $state['pending_otp'] = [
+            'email' => $email,
+            'hash' => $this->hashOtp($email, $code),
+            'expires_at' => $now + $this->otpTtlSeconds,
+            'attempts' => 0,
+            'cooldown_until' => $now + $this->resendCooldownSeconds,
+        ];
+
+        if ($autoStart) {
+            $this->markAutoOtpSentForEmail($email);
+        }
+
+        return [
+            'ok' => true,
+            'code' => $code,
+            'expires_in' => $this->otpTtlSeconds,
+            'cooldown_seconds' => $this->resendCooldownSeconds,
+        ];
+    }
+
+    public function clearPendingOtp(): void
+    {
+        $state =& $this->state();
+        unset($state['pending_otp']);
+    }
+
+    /**
+     * @return array{ok: bool, reason?: string, status_code?: int, message?: string, attempts_left?: int}
+     */
+    public function verifyOtp(string $email, string $code): array
+    {
+        if (!$this->required) {
+            $email = $this->normalizeEmail($email);
+            if ($email !== '') {
+                $now = time();
+                $state =& $this->state();
+                $state['verified_email'] = $email;
+                $state['verified_at'] = $now;
+                $state['last_activity_at'] = $now;
+            }
+
+            return ['ok' => true];
+        }
+
+        $email = $this->normalizeEmail($email);
+        $code = trim($code);
+        if ($email === '' || !preg_match('/^\d{6}$/', $code)) {
+            return [
+                'ok' => false,
+                'reason' => 'invalid_code',
+                'status_code' => 422,
+                'message' => 'Bitte einen gueltigen 6-stelligen Code eingeben.',
+            ];
+        }
+
+        $pending = $this->pendingState();
+        $pendingEmail = $this->normalizeEmail((string) ($pending['email'] ?? ''));
+        if ($pending === [] || $pendingEmail === '' || !hash_equals($pendingEmail, $email)) {
+            return [
+                'ok' => false,
+                'reason' => 'auth_required',
+                'status_code' => 401,
+                'message' => 'Bitte zuerst einen neuen Code anfordern.',
+            ];
+        }
+
+        $now = time();
+        $expiresAt = (int) ($pending['expires_at'] ?? 0);
+        if ($expiresAt <= 0 || $expiresAt < $now) {
+            $this->clearPendingOtp();
+            return [
+                'ok' => false,
+                'reason' => 'expired',
+                'status_code' => 401,
+                'message' => 'Der Code ist abgelaufen. Bitte neuen Code anfordern.',
+            ];
+        }
+
+        $expectedHash = (string) ($pending['hash'] ?? '');
+        $actualHash = $this->hashOtp($email, $code);
+        if ($expectedHash === '' || !hash_equals($expectedHash, $actualHash)) {
+            $state =& $this->state();
+            $attempts = (int) ($pending['attempts'] ?? 0) + 1;
+            $attemptsLeft = $this->otpAttemptLimit - $attempts;
+
+            if ($attemptsLeft <= 0) {
+                unset($state['pending_otp']);
+                return [
+                    'ok' => false,
+                    'reason' => 'attempt_limit',
+                    'status_code' => 401,
+                    'message' => 'Zu viele falsche Versuche. Bitte neuen Code anfordern.',
+                    'attempts_left' => 0,
+                ];
+            }
+
+            $pending['attempts'] = $attempts;
+            $state['pending_otp'] = $pending;
+
+            return [
+                'ok' => false,
+                'reason' => 'invalid_code',
+                'status_code' => 401,
+                'message' => 'Der Code ist ungueltig.',
+                'attempts_left' => $attemptsLeft,
+            ];
+        }
+
+        $state =& $this->state();
+        unset($state['pending_otp']);
+        $state['verified_email'] = $email;
+        $state['verified_at'] = $now;
+        $state['last_activity_at'] = $now;
+
+        return ['ok' => true];
+    }
+
+    public function hasAutoOtpSentForEmail(string $email): bool
+    {
+        $email = $this->normalizeEmail($email);
+        if ($email === '') {
+            return false;
+        }
+
+        $state = $this->state();
+        $marker = $state['auto_otp_sent'] ?? [];
+        if (!is_array($marker)) {
+            return false;
+        }
+
+        return isset($marker[$email]) && is_int($marker[$email]);
+    }
+
+    public function markAutoOtpSentForEmail(string $email): void
+    {
+        $email = $this->normalizeEmail($email);
+        if ($email === '') {
+            return;
+        }
+
+        $state =& $this->state();
+        if (!isset($state['auto_otp_sent']) || !is_array($state['auto_otp_sent'])) {
+            $state['auto_otp_sent'] = [];
+        }
+
+        $state['auto_otp_sent'][$email] = time();
+    }
+
+    /**
+     * @return array{ok: bool, reason?: string, status_code?: int, message?: string}
+     */
+    public function assertVerifiedForEmail(string $email, ?int $clientLastUserActivityAt = null): array
+    {
+        if (!$this->required) {
+            return ['ok' => true];
+        }
+
+        $email = $this->normalizeEmail($email);
+        if ($email === '') {
+            return [
+                'ok' => false,
+                'reason' => 'auth_required',
+                'status_code' => 401,
+                'message' => 'Bitte zuerst E-Mail und Code bestaetigen.',
+            ];
+        }
+
+        $state =& $this->state();
+        $verifiedEmail = $this->normalizeEmail((string) ($state['verified_email'] ?? ''));
+        if ($verifiedEmail === '' || !hash_equals($verifiedEmail, $email)) {
+            return [
+                'ok' => false,
+                'reason' => 'auth_required',
+                'status_code' => 401,
+                'message' => 'Bitte zuerst E-Mail und Code bestaetigen.',
+            ];
+        }
+
+        $now = time();
+        $verifiedAt = (int) ($state['verified_at'] ?? 0);
+        $lastActivityAt = (int) ($state['last_activity_at'] ?? 0);
+        if ($lastActivityAt <= 0) {
+            $lastActivityAt = $verifiedAt > 0 ? $verifiedAt : $now;
+        }
+
+        $clientActivity = $this->sanitizeClientActivityTimestamp($clientLastUserActivityAt, $now);
+        $effectiveLastActivity = $lastActivityAt;
+        if ($clientActivity !== null && $clientActivity > $effectiveLastActivity) {
+            $effectiveLastActivity = $clientActivity;
+        }
+
+        if (($now - $effectiveLastActivity) > $this->inactivitySeconds) {
+            $this->clearVerifiedState();
+            return [
+                'ok' => false,
+                'reason' => 'auth_expired',
+                'status_code' => 401,
+                'message' => 'Sitzung abgelaufen. Bitte E-Mail erneut verifizieren.',
+            ];
+        }
+
+        $state['last_activity_at'] = $effectiveLastActivity;
+
+        return ['ok' => true];
+    }
+
+    public function clearVerifiedState(): void
+    {
+        $state =& $this->state();
+        unset($state['verified_email'], $state['verified_at'], $state['last_activity_at']);
+    }
+
+    /**
+     * @return array<string, mixed>
+     */
+    private function pendingState(): array
+    {
+        $state = $this->state();
+        $pending = $state['pending_otp'] ?? [];
+        return is_array($pending) ? $pending : [];
+    }
+
+    private function normalizeEmail(string $email): string
+    {
+        return strtolower(trim($email));
+    }
+
+    private function hashOtp(string $email, string $code): string
+    {
+        $secret = (string) ($_SESSION['_csrf_token'] ?? session_id());
+        return hash_hmac('sha256', $this->normalizeEmail($email) . '|' . $code, $secret);
+    }
+
+    private function sanitizeClientActivityTimestamp(?int $ts, int $now): ?int
+    {
+        if ($ts === null || $ts <= 0) {
+            return null;
+        }
+
+        if ($ts > ($now + 120)) {
+            return null;
+        }
+
+        if ($ts < ($now - 86400 * 7)) {
+            return null;
+        }
+
+        return $ts;
+    }
+
+    /**
+     * @return array<string, mixed>
+     */
+    private function &state(): array
+    {
+        if (!isset($_SESSION[self::SESSION_KEY]) || !is_array($_SESSION[self::SESSION_KEY])) {
+            $_SESSION[self::SESSION_KEY] = [];
+        }
+
+        /** @var array<string, mixed> $state */
+        $state =& $_SESSION[self::SESSION_KEY];
+        return $state;
+    }
+}