Sfoglia il codice sorgente

implementing panel for confirming jugend anträge

Medowar 1 mese fa
parent
commit
13f69bc178

+ 11 - 0
config/app.sample.php

@@ -54,6 +54,17 @@ return [
         ],
         'session_timeout_seconds' => 3600,
     ],
+    'entra' => [
+        // Microsoft Entra (Azure AD) OIDC login for the Jugendwart-Portal (portal/).
+        // Real values gehören in config/app.local.php (nicht ins Repo).
+        'tenant_id' => '',                          // Verzeichnis-(Mandanten-)ID
+        'client_id' => '',                          // Anwendungs-(Client-)ID der App-Registrierung
+        'client_secret' => '',                      // Geheimer Clientschlüssel (nur lokal)
+        'redirect_uri' => '',                       // z.B. https://host/portal/callback.php
+        // Zugelassene Nutzer (E-Mail/UPN, kleingeschrieben). Leer = niemand darf rein.
+        'allowed_users' => [],
+        'session_timeout_seconds' => 3600,
+    ],
     'storage' => [
         'drafts' => $root . '/storage/drafts',
         'submissions' => $root . '/storage/submissions',

+ 1 - 0
config/mail.sample.php

@@ -12,6 +12,7 @@ return [
         'admin' => 'Neuer Mitgliedsantrag',
         'applicant' => 'Bestätigung deines Mitgliedsantrags',
         'otp' => 'Ihr Sicherheitscode für den Mitgliedsantrag',
+        'signed_form_received' => 'Unterschriebenes Formular eingegangen',
     ],
     'otp' => [
         'text_template' => "Ihr Sicherheitscode lautet: {{code}}\nDer Code ist {{ttl_minutes}} Minuten gültig.",

+ 32 - 0
portal/callback.php

@@ -0,0 +1,32 @@
+<?php
+
+declare(strict_types=1);
+
+use App\App\Bootstrap;
+use App\Security\EntraAuth;
+
+require dirname(__DIR__) . '/src/autoload.php';
+Bootstrap::init();
+
+$auth = new EntraAuth();
+
+// Entra reported an error (e.g. user cancelled or consent denied).
+if (isset($_GET['error'])) {
+    Bootstrap::log('portal', 'Entra-Callback-Fehler: ' . (string) ($_GET['error'])
+        . ' - ' . (string) ($_GET['error_description'] ?? ''));
+    header('Location: ' . Bootstrap::url('portal/login.php?error=auth_failed'));
+    exit;
+}
+
+$code = (string) ($_GET['code'] ?? '');
+$state = (string) ($_GET['state'] ?? '');
+
+if ($auth->handleCallback($code, $state)) {
+    header('Location: ' . Bootstrap::url('portal/index.php'));
+    exit;
+}
+
+// Distinguish "signed in but not allowlisted" is not possible here without extra state;
+// a generic failure keeps the flow simple and avoids leaking allowlist membership.
+header('Location: ' . Bootstrap::url('portal/login.php?error=auth_failed'));
+exit;

+ 69 - 0
portal/confirm.php

@@ -0,0 +1,69 @@
+<?php
+
+declare(strict_types=1);
+
+use App\App\Bootstrap;
+use App\Security\EntraAuth;
+use App\Security\Csrf;
+use App\Storage\JsonStore;
+use App\Mail\Mailer;
+
+require dirname(__DIR__) . '/src/autoload.php';
+Bootstrap::init();
+
+$auth = new EntraAuth();
+$auth->requireLogin();
+
+if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
+    header('Location: ' . Bootstrap::url('portal/index.php'));
+    exit;
+}
+
+if (!Csrf::validate((string) ($_POST['csrf'] ?? ''))) {
+    header('Location: ' . Bootstrap::url('portal/index.php'));
+    exit;
+}
+
+$applicationKey = (string) ($_POST['id'] ?? '');
+$store = new JsonStore();
+$submission = $store->getSubmissionByKey($applicationKey);
+
+// Only minor submissions are actionable here.
+if ($submission === null || !(bool) ($submission['is_minor_submission'] ?? false)) {
+    header('Location: ' . Bootstrap::url('portal/index.php'));
+    exit;
+}
+
+// Already confirmed → no duplicate email.
+if (isset($submission['signed_form_received']) && is_array($submission['signed_form_received'])) {
+    header('Location: ' . Bootstrap::url('portal/index.php?done=already'));
+    exit;
+}
+
+$user = $auth->user() ?? ['email' => '', 'name' => ''];
+$result = $store->markSignedFormReceived($applicationKey, [
+    'received_by' => (string) ($user['name'] ?? ''),
+    'received_by_email' => (string) ($user['email'] ?? ''),
+]);
+
+if ($result === null) {
+    header('Location: ' . Bootstrap::url('portal/index.php'));
+    exit;
+}
+
+// Only the call that actually set the marker sends the notification (no duplicate mail).
+if (!$result['newly_marked']) {
+    header('Location: ' . Bootstrap::url('portal/index.php?done=already'));
+    exit;
+}
+
+$updated = $result['submission'];
+
+Bootstrap::log('portal', 'Eingang unterschriebenes Formular bestätigt für ' . (string) ($updated['email'] ?? '')
+    . ' durch ' . (string) ($user['email'] ?? ''));
+
+$mailer = new Mailer();
+$mailer->sendSignedFormReceivedMail($updated);
+
+header('Location: ' . Bootstrap::url('portal/index.php?done=1'));
+exit;

+ 122 - 0
portal/index.php

@@ -0,0 +1,122 @@
+<?php
+
+declare(strict_types=1);
+
+use App\App\Bootstrap;
+use App\Security\EntraAuth;
+use App\Security\Csrf;
+use App\Storage\JsonStore;
+
+require dirname(__DIR__) . '/src/autoload.php';
+Bootstrap::init();
+$app = Bootstrap::config('app');
+
+$auth = new EntraAuth();
+$auth->requireLogin();
+$currentUser = $auth->user() ?? ['email' => '', 'name' => ''];
+
+$store = new JsonStore();
+$list = array_values(array_filter(
+    $store->listSubmissions(),
+    static fn (array $item): bool => (bool) ($item['is_minor_submission'] ?? false)
+));
+
+$flash = '';
+if (($_GET['done'] ?? '') === '1') {
+    $flash = 'Eingang des unterschriebenen Formulars wurde bestätigt und die Empfänger wurden benachrichtigt.';
+} elseif (($_GET['done'] ?? '') === 'already') {
+    $flash = 'Für diesen Antrag war der Eingang bereits bestätigt.';
+}
+
+$formatDate = static function (string $value): string {
+    $ts = strtotime($value);
+    return $ts !== false ? date('d.m.Y H:i', $ts) : $value;
+};
+
+$csrf = Csrf::token();
+?><!doctype html>
+<html lang="de">
+<head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1">
+    <title>Jugendwart-Portal</title>
+    <link rel="stylesheet" href="<?= htmlspecialchars(Bootstrap::url('assets/css/tokens.css')) ?>">
+    <link rel="stylesheet" href="<?= htmlspecialchars(Bootstrap::url('assets/css/base.css')) ?>">
+</head>
+<body class="admin-page">
+<header class="site-header">
+    <div class="container header-inner">
+        <a class="brand" href="<?= htmlspecialchars(Bootstrap::url('portal/index.php')) ?>">
+            <img class="brand-logo" src="<?= htmlspecialchars(Bootstrap::url('assets/images/feuerwehr-logo-invers.webp')) ?>" alt="Feuerwehr Logo">
+            <div class="brand-title"><?= htmlspecialchars((string) ($app['project_name'] ?? 'Jugendwart-Portal')) ?></div>
+        </a>
+    </div>
+</header>
+<main class="container">
+    <section class="card">
+        <div class="admin-toolbar">
+            <div>
+                <h1>Unterschriebene Formulare (Minderjährige)</h1>
+                <p>Angemeldet als <?= htmlspecialchars($currentUser['name'] !== '' ? $currentUser['name'] : $currentUser['email']) ?>
+                    &middot; <a href="<?= htmlspecialchars(Bootstrap::url('portal/login.php?logout=1')) ?>">Abmelden</a></p>
+            </div>
+        </div>
+
+        <?php if ($flash !== ''): ?>
+            <p class="alert alert-success"><?= htmlspecialchars($flash) ?></p>
+        <?php endif; ?>
+
+        <p>Bitte bestätigen Sie hier den Eingang des handschriftlich unterschriebenen Formulars
+            (Einverständniserklärung Minderjährige). Dadurch werden die Empfänger des Antrags automatisch informiert.</p>
+
+        <?php if (empty($list)): ?>
+            <p>Keine minderjährigen Anträge vorhanden.</p>
+        <?php else: ?>
+            <div class="table-responsive">
+                <table class="responsive-table table-dense admin-submissions-table">
+                    <thead>
+                        <tr>
+                            <th>Vorname</th>
+                            <th>Nachname</th>
+                            <th>E-Mail</th>
+                            <th>Eingereicht</th>
+                            <th>Status</th>
+                        </tr>
+                    </thead>
+                    <tbody>
+                        <?php foreach ($list as $item):
+                            $formData = (array) ($item['form_data'] ?? []);
+                            $received = (array) ($item['signed_form_received'] ?? []);
+                            $isReceived = $received !== [];
+                            $key = (string) ($item['application_key'] ?? '');
+                            ?>
+                            <tr>
+                                <td data-label="Vorname"><?= htmlspecialchars((string) ($formData['vorname'] ?? '')) ?></td>
+                                <td data-label="Nachname"><?= htmlspecialchars((string) ($formData['nachname'] ?? '')) ?></td>
+                                <td data-label="E-Mail"><?= htmlspecialchars((string) ($item['email'] ?? '')) ?></td>
+                                <td data-label="Eingereicht"><?= htmlspecialchars($formatDate((string) ($item['submitted_at'] ?? ''))) ?></td>
+                                <td data-label="Status">
+                                    <?php if ($isReceived): ?>
+                                        Erhalten am <?= htmlspecialchars($formatDate((string) ($received['received_at'] ?? ''))) ?>
+                                        <?php $rBy = trim((string) ($received['received_by'] ?? ($received['received_by_email'] ?? '')));
+                                        if ($rBy !== ''): ?>
+                                            durch <?= htmlspecialchars($rBy) ?>
+                                        <?php endif; ?>
+                                    <?php else: ?>
+                                        <form method="post" action="<?= htmlspecialchars(Bootstrap::url('portal/confirm.php')) ?>">
+                                            <input type="hidden" name="csrf" value="<?= htmlspecialchars($csrf) ?>">
+                                            <input type="hidden" name="id" value="<?= htmlspecialchars($key) ?>">
+                                            <button type="submit" class="btn">Formular erhalten</button>
+                                        </form>
+                                    <?php endif; ?>
+                                </td>
+                            </tr>
+                        <?php endforeach; ?>
+                    </tbody>
+                </table>
+            </div>
+        <?php endif; ?>
+    </section>
+</main>
+</body>
+</html>

+ 66 - 0
portal/login.php

@@ -0,0 +1,66 @@
+<?php
+
+declare(strict_types=1);
+
+use App\App\Bootstrap;
+use App\Security\EntraAuth;
+
+require dirname(__DIR__) . '/src/autoload.php';
+Bootstrap::init();
+$app = Bootstrap::config('app');
+
+$auth = new EntraAuth();
+
+if (isset($_GET['logout']) && $_GET['logout'] === '1') {
+    $auth->logout();
+    header('Location: ' . Bootstrap::url('portal/login.php'));
+    exit;
+}
+
+if ($auth->isLoggedIn()) {
+    header('Location: ' . Bootstrap::url('portal/index.php'));
+    exit;
+}
+
+$errorMessages = [
+    'access_denied' => 'Anmeldung nicht möglich: Ihr Konto ist für dieses Portal nicht freigeschaltet.',
+    'auth_failed' => 'Anmeldung fehlgeschlagen. Bitte erneut versuchen.',
+];
+$error = $errorMessages[(string) ($_GET['error'] ?? '')] ?? '';
+
+$configured = $auth->isConfigured();
+$loginUrl = $configured ? $auth->getAuthorizationUrl() : '';
+?><!doctype html>
+<html lang="de">
+<head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1">
+    <title>Jugendwart-Portal Login</title>
+    <link rel="stylesheet" href="<?= htmlspecialchars(Bootstrap::url('assets/css/tokens.css')) ?>">
+    <link rel="stylesheet" href="<?= htmlspecialchars(Bootstrap::url('assets/css/base.css')) ?>">
+</head>
+<body class="admin-page">
+<header class="site-header">
+    <div class="container header-inner">
+        <a class="brand" href="<?= htmlspecialchars(Bootstrap::url('portal/login.php')) ?>">
+            <img class="brand-logo" src="<?= htmlspecialchars(Bootstrap::url('assets/images/feuerwehr-logo-invers.webp')) ?>" alt="Feuerwehr Logo">
+            <div class="brand-title"><?= htmlspecialchars((string) ($app['project_name'] ?? 'Jugendwart-Portal')) ?></div>
+        </a>
+    </div>
+</header>
+<main class="container">
+    <section class="card auth-container">
+        <h1>Jugendwart-Portal</h1>
+        <p>Anmeldung für Jugendbetreuer zur Bestätigung eingegangener unterschriebener Formulare.</p>
+        <?php if ($error !== ''): ?>
+            <p class="alert alert-error"><?= htmlspecialchars($error) ?></p>
+        <?php endif; ?>
+        <?php if ($configured): ?>
+            <a class="btn" href="<?= htmlspecialchars($loginUrl) ?>">Mit Microsoft anmelden</a>
+        <?php else: ?>
+            <p class="alert alert-warning">Die Microsoft-Entra-Anmeldung ist noch nicht konfiguriert.</p>
+        <?php endif; ?>
+    </section>
+</main>
+</body>
+</html>

+ 156 - 17
src/mail/mailer.php

@@ -105,23 +105,7 @@ final class Mailer
         $textBody = $this->renderAdminText($submission, $isMinorSubmission, $uploadWarning);
 
         $formData = (array) ($submission['form_data'] ?? []);
-        $ccEmails = [];
-        $notifications = (array) ($this->schema->raw()['additional_notifications'] ?? []);
-        $validator = new \App\Form\Validator($this->schema);
-        
-        foreach ($notifications as $notification) {
-            if (!isset($notification['condition']) || !is_array($notification['condition'])) {
-                continue;
-            }
-            if ($validator->evaluateCondition($notification['condition'], $formData)) {
-                $ccs = (array) ($notification['cc'] ?? []);
-                foreach ($ccs as $cc) {
-                    if (is_string($cc) && filter_var($cc, FILTER_VALIDATE_EMAIL)) {
-                        $ccEmails[] = trim($cc);
-                    }
-                }
-            }
-        }
+        $ccEmails = $this->collectConditionalCcs($formData);
 
         foreach ($recipients as $recipient) {
             if (!is_string($recipient) || $recipient === '') {
@@ -195,6 +179,86 @@ final class Mailer
         }
     }
 
+    /**
+     * Notify the same recipients as the original submission (incl. the conditional CC,
+     * e.g. the Jugendwart team) that the hand-signed minor consent form has been received.
+     *
+     * @param array<string, mixed> $submission
+     */
+    public function sendSignedFormReceivedMail(array $submission): void
+    {
+        Bootstrap::log('mail', 'Versandprozess "Formular erhalten" gestartet für: ' . (string) ($submission['email'] ?? 'unbekannt'));
+
+        $recipients = (array) ($this->mailConfig['recipients'] ?? []);
+        $subject = (string) ($this->mailConfig['subjects']['signed_form_received'] ?? 'Unterschriebenes Formular eingegangen');
+        $formData = (array) ($submission['form_data'] ?? []);
+        $ccEmails = $this->collectConditionalCcs($formData);
+
+        $htmlBody = $this->renderSignedFormReceivedHtml($submission);
+        $textBody = $this->renderSignedFormReceivedText($submission);
+
+        foreach ($recipients as $recipient) {
+            if (!is_string($recipient) || $recipient === '') {
+                continue;
+            }
+
+            try {
+                $mail = $this->createMailBuilder();
+                $mail->setTo($recipient)
+                    ->setSubject($subject)
+                    ->setHtmlBody($htmlBody)
+                    ->setTextBody($textBody);
+
+                foreach ($ccEmails as $ccEmail) {
+                    $mail->addCc($ccEmail);
+                }
+
+                if (!$mail->send()) {
+                    Bootstrap::log('mail', 'Versand "Formular erhalten" fehlgeschlagen: ' . $recipient . ' - ' . $mail->getErrorInfo());
+                    continue;
+                }
+
+                $ccInfo = $ccEmails !== [] ? (' | CC: ' . implode(', ', $ccEmails)) : '';
+                Bootstrap::log('mail', 'Versand "Formular erhalten" erfolgreich: ' . $recipient . $ccInfo);
+            } catch (\Throwable $e) {
+                Bootstrap::log('mail', 'Versand "Formular erhalten" fehlgeschlagen: ' . $recipient . ' - ' . $e->getMessage());
+            }
+        }
+
+        Bootstrap::log('mail', 'Versandprozess "Formular erhalten" abgeschlossen für: ' . (string) ($submission['email'] ?? 'unbekannt'));
+    }
+
+    /**
+     * Collect CC addresses from the schema's conditional `additional_notifications` whose
+     * condition matches the submitted form data. Shared by the admin and the
+     * "signed form received" mails so both hit the same recipients.
+     *
+     * @param array<string, mixed> $formData
+     * @return array<int, string>
+     */
+    private function collectConditionalCcs(array $formData): array
+    {
+        $ccEmails = [];
+        $notifications = (array) ($this->schema->raw()['additional_notifications'] ?? []);
+        $validator = new \App\Form\Validator($this->schema);
+
+        foreach ($notifications as $notification) {
+            if (!isset($notification['condition']) || !is_array($notification['condition'])) {
+                continue;
+            }
+            if ($validator->evaluateCondition($notification['condition'], $formData)) {
+                $ccs = (array) ($notification['cc'] ?? []);
+                foreach ($ccs as $cc) {
+                    if (is_string($cc) && filter_var($cc, FILTER_VALIDATE_EMAIL)) {
+                        $ccEmails[] = trim($cc);
+                    }
+                }
+            }
+        }
+
+        return $ccEmails;
+    }
+
     // ---------------------------------------------------------------
     // Mail builder
     // ---------------------------------------------------------------
@@ -407,6 +471,81 @@ final class Mailer
             . 'Bitte die Bearbeitung erst nach Eingang des handschriftlich unterschriebenen Formulars fortsetzen.';
     }
 
+    /** @param array<string, mixed> $submission */
+    private function renderSignedFormReceivedHtml(array $submission): string
+    {
+        $received = (array) ($submission['signed_form_received'] ?? []);
+        $by = trim((string) ($received['received_by'] ?? ''));
+        $byEmail = trim((string) ($received['received_by_email'] ?? ''));
+        $confirmedBy = $by !== '' ? $by : $byEmail;
+
+        $h = '<!DOCTYPE html><html><head><meta charset="UTF-8"></head><body style="font-family:Arial,sans-serif;color:#333;max-width:700px;margin:0 auto">';
+        $h .= '<h2 style="color:#c0392b">Unterschriebenes Formular eingegangen</h2>';
+        $h .= '<p>Das handschriftlich unterschriebene Formular (Einverständniserklärung Minderjährige) '
+            . 'zum folgenden Mitgliedsantrag wurde erhalten. Die Bearbeitung kann fortgesetzt werden.</p>';
+        $h .= '<table style="width:100%;border-collapse:collapse">';
+        $h .= $this->signedFormReceivedRowHtml('Antragsteller/in', $this->applicantName($submission));
+        $h .= $this->signedFormReceivedRowHtml('E-Mail', (string) ($submission['email'] ?? ''));
+        $h .= $this->signedFormReceivedRowHtml('Antrag eingereicht', $this->formatTimestamp($submission));
+        $h .= $this->signedFormReceivedRowHtml('Eingang bestätigt am', $this->formatReceivedTimestamp($received));
+        if ($confirmedBy !== '') {
+            $h .= $this->signedFormReceivedRowHtml('Bestätigt durch', $confirmedBy);
+        }
+        $h .= '</table>';
+        $h .= '</body></html>';
+
+        return $h;
+    }
+
+    /** @param array<string, mixed> $submission */
+    private function renderSignedFormReceivedText(array $submission): string
+    {
+        $received = (array) ($submission['signed_form_received'] ?? []);
+        $by = trim((string) ($received['received_by'] ?? ''));
+        $byEmail = trim((string) ($received['received_by_email'] ?? ''));
+        $confirmedBy = $by !== '' ? $by : $byEmail;
+
+        $t = "UNTERSCHRIEBENES FORMULAR EINGEGANGEN\n\n";
+        $t .= "Das handschriftlich unterschriebene Formular (Einverständniserklärung Minderjährige) "
+            . "zum folgenden Mitgliedsantrag wurde erhalten. Die Bearbeitung kann fortgesetzt werden.\n\n";
+        $t .= 'Antragsteller/in: ' . $this->applicantName($submission) . "\n";
+        $t .= 'E-Mail: ' . (string) ($submission['email'] ?? '') . "\n";
+        $t .= 'Antrag eingereicht: ' . $this->formatTimestamp($submission) . "\n";
+        $t .= 'Eingang bestätigt am: ' . $this->formatReceivedTimestamp($received) . "\n";
+        if ($confirmedBy !== '') {
+            $t .= 'Bestätigt durch: ' . $confirmedBy . "\n";
+        }
+
+        return $t;
+    }
+
+    private function signedFormReceivedRowHtml(string $label, string $value): string
+    {
+        return '<tr><td style="padding:4px 8px;border-bottom:1px solid #eee;font-weight:bold;vertical-align:top;width:40%">'
+            . $this->esc($label) . '</td>'
+            . '<td style="padding:4px 8px;border-bottom:1px solid #eee;vertical-align:top">'
+            . $this->esc($value) . '</td></tr>';
+    }
+
+    /** @param array<string, mixed> $submission */
+    private function applicantName(array $submission): string
+    {
+        $formData = (array) ($submission['form_data'] ?? []);
+        $name = trim(
+            (string) ($formData['vorname'] ?? '') . ' ' . (string) ($formData['nachname'] ?? '')
+        );
+
+        return $name !== '' ? $name : (string) ($submission['email'] ?? '');
+    }
+
+    /** @param array<string, mixed> $received */
+    private function formatReceivedTimestamp(array $received): string
+    {
+        $ts = (string) ($received['received_at'] ?? '');
+        $parsed = strtotime($ts);
+        return $parsed !== false ? date('d.m.Y H:i', $parsed) : $ts;
+    }
+
     /**
      * @param array<string, mixed> $submission
      * @return array{

+ 334 - 0
src/security/entraauth.php

@@ -0,0 +1,334 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Security;
+
+use App\App\Bootstrap;
+
+/**
+ * Microsoft Entra (Azure AD) OpenID-Connect authorization-code login for the
+ * Jugendwart-Portal (portal/). Hand-rolled to match the project's zero-dependency
+ * convention (no Composer). The identity is authenticated by Entra; authorization is
+ * decided by the `entra.allowed_users` allowlist in config.
+ *
+ * Session key `portal_user` is intentionally distinct from the admin session
+ * (`admin_logged_in`) so the two portals never collide.
+ */
+final class EntraAuth
+{
+    private const SESSION_USER_KEY = 'portal_user';
+    private const SESSION_FLOW_KEY = '_portal_oauth';
+
+    /** @var array<string, mixed> */
+    private array $entra;
+
+    public function __construct()
+    {
+        $app = Bootstrap::config('app');
+        $this->entra = is_array($app['entra'] ?? null) ? $app['entra'] : [];
+    }
+
+    public function isConfigured(): bool
+    {
+        return $this->tenantId() !== ''
+            && $this->clientId() !== ''
+            && (string) ($this->entra['client_secret'] ?? '') !== ''
+            && $this->redirectUri() !== '';
+    }
+
+    public function isLoggedIn(): bool
+    {
+        $user = $_SESSION[self::SESSION_USER_KEY] ?? null;
+        return is_array($user)
+            && is_string($user['email'] ?? null)
+            && ($user['email'] ?? '') !== '';
+    }
+
+    /** @return array{email: string, name: string, login_at: int}|null */
+    public function user(): ?array
+    {
+        if (!$this->isLoggedIn()) {
+            return null;
+        }
+
+        $user = (array) $_SESSION[self::SESSION_USER_KEY];
+        return [
+            'email' => (string) ($user['email'] ?? ''),
+            'name' => (string) ($user['name'] ?? ''),
+            'login_at' => (int) ($user['login_at'] ?? 0),
+        ];
+    }
+
+    /**
+     * Build the Entra authorize URL and persist the anti-forgery state, the OIDC nonce
+     * and the PKCE verifier in the session for later verification in handleCallback().
+     */
+    public function getAuthorizationUrl(): string
+    {
+        $state = bin2hex(random_bytes(16));
+        $nonce = bin2hex(random_bytes(16));
+        $codeVerifier = self::base64UrlEncode(random_bytes(32));
+        $codeChallenge = self::base64UrlEncode(hash('sha256', $codeVerifier, true));
+
+        $_SESSION[self::SESSION_FLOW_KEY] = [
+            'state' => $state,
+            'nonce' => $nonce,
+            'code_verifier' => $codeVerifier,
+            'created_at' => time(),
+        ];
+
+        $params = [
+            'client_id' => $this->clientId(),
+            'response_type' => 'code',
+            'redirect_uri' => $this->redirectUri(),
+            'response_mode' => 'query',
+            'scope' => 'openid profile email',
+            'state' => $state,
+            'nonce' => $nonce,
+            'code_challenge' => $codeChallenge,
+            'code_challenge_method' => 'S256',
+        ];
+
+        return 'https://login.microsoftonline.com/' . rawurlencode($this->tenantId())
+            . '/oauth2/v2.0/authorize?' . http_build_query($params);
+    }
+
+    /**
+     * Exchange the authorization code, validate the id_token claims and, if the user is on
+     * the allowlist, establish the portal session. Returns false on any failure.
+     */
+    public function handleCallback(string $code, string $state): bool
+    {
+        $flow = $_SESSION[self::SESSION_FLOW_KEY] ?? null;
+        unset($_SESSION[self::SESSION_FLOW_KEY]);
+
+        if ($code === '' || $state === '' || !is_array($flow)) {
+            $this->log('Callback abgebrochen: fehlende Parameter oder kein Flow-State.');
+            return false;
+        }
+        if (!is_string($flow['state'] ?? null) || !hash_equals($flow['state'], $state)) {
+            $this->log('Callback abgebrochen: state stimmt nicht überein.');
+            return false;
+        }
+
+        $tokens = $this->exchangeCode($code, (string) ($flow['code_verifier'] ?? ''));
+        if ($tokens === null || !is_string($tokens['id_token'] ?? null)) {
+            $this->log('Callback abgebrochen: Token-Austausch fehlgeschlagen.');
+            return false;
+        }
+
+        $claims = $this->decodeIdTokenClaims((string) $tokens['id_token']);
+        if ($claims === null) {
+            $this->log('Callback abgebrochen: id_token konnte nicht dekodiert werden.');
+            return false;
+        }
+
+        if (!$this->claimsAreValid($claims, (string) ($flow['nonce'] ?? ''))) {
+            return false;
+        }
+
+        $email = strtolower(trim(
+            (string) ($claims['preferred_username'] ?? $claims['email'] ?? $claims['upn'] ?? '')
+        ));
+        $name = trim((string) ($claims['name'] ?? $email));
+
+        if ($email === '' || !$this->isAllowed($email)) {
+            $this->log('Zugriff verweigert (nicht auf Allowlist): ' . ($email !== '' ? $email : 'unbekannt'));
+            return false;
+        }
+
+        $_SESSION[self::SESSION_USER_KEY] = [
+            'email' => $email,
+            'name' => $name,
+            'login_at' => time(),
+        ];
+        session_regenerate_id(true);
+        $this->log('Portal-Login erfolgreich: ' . $email);
+
+        return true;
+    }
+
+    public function logout(): void
+    {
+        unset($_SESSION[self::SESSION_USER_KEY], $_SESSION[self::SESSION_FLOW_KEY]);
+        $_SESSION = [];
+        if (ini_get('session.use_cookies')) {
+            $params = session_get_cookie_params();
+            setcookie(session_name(), '', time() - 42000, $params['path'], $params['domain'], (bool) $params['secure'], (bool) $params['httponly']);
+        }
+        session_destroy();
+    }
+
+    public function requireLogin(): void
+    {
+        $timeout = (int) ($this->entra['session_timeout_seconds'] ?? 3600);
+        $loginAt = (int) ($_SESSION[self::SESSION_USER_KEY]['login_at'] ?? 0);
+
+        if ($this->isLoggedIn() && $timeout > 0 && $loginAt > 0 && (time() - $loginAt) > $timeout) {
+            $this->logout();
+        }
+
+        if (!$this->isLoggedIn()) {
+            header('Location: ' . Bootstrap::url('portal/login.php'));
+            exit;
+        }
+    }
+
+    // ------------------------------------------------------------------
+    // Internals
+    // ------------------------------------------------------------------
+
+    /** @return array<string, mixed>|null */
+    private function exchangeCode(string $code, string $codeVerifier): ?array
+    {
+        $url = 'https://login.microsoftonline.com/' . rawurlencode($this->tenantId()) . '/oauth2/v2.0/token';
+        $post = http_build_query([
+            'client_id' => $this->clientId(),
+            'client_secret' => (string) ($this->entra['client_secret'] ?? ''),
+            'grant_type' => 'authorization_code',
+            'code' => $code,
+            'redirect_uri' => $this->redirectUri(),
+            'scope' => 'openid profile email',
+            'code_verifier' => $codeVerifier,
+        ]);
+
+        $ch = curl_init($url);
+        if ($ch === false) {
+            return null;
+        }
+
+        curl_setopt_array($ch, [
+            CURLOPT_POST => true,
+            CURLOPT_POSTFIELDS => $post,
+            CURLOPT_RETURNTRANSFER => true,
+            CURLOPT_HTTPHEADER => ['Content-Type: application/x-www-form-urlencoded'],
+            CURLOPT_TIMEOUT => 15,
+            CURLOPT_SSL_VERIFYPEER => true,
+            CURLOPT_SSL_VERIFYHOST => 2,
+        ]);
+
+        $response = curl_exec($ch);
+        $status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
+        $error = curl_error($ch);
+        curl_close($ch);
+
+        if (!is_string($response) || $response === '') {
+            $this->log('Token-Endpoint ohne Antwort: ' . $error);
+            return null;
+        }
+
+        $decoded = json_decode($response, true);
+        if (!is_array($decoded)) {
+            $this->log('Token-Endpoint lieferte kein JSON (HTTP ' . $status . ').');
+            return null;
+        }
+
+        if ($status !== 200) {
+            $this->log('Token-Austausch HTTP ' . $status . ': ' . (string) ($decoded['error'] ?? 'unbekannt'));
+            return null;
+        }
+
+        return $decoded;
+    }
+
+    /**
+     * Decode the id_token payload. The token is fetched directly from Microsoft's token
+     * endpoint over server-to-server TLS (never through the browser), so validating the
+     * claims without JWKS signature verification is a deliberate, safe simplification that
+     * keeps the zero-dependency convention.
+     *
+     * @return array<string, mixed>|null
+     */
+    private function decodeIdTokenClaims(string $idToken): ?array
+    {
+        $parts = explode('.', $idToken);
+        if (count($parts) !== 3) {
+            return null;
+        }
+
+        $payload = self::base64UrlDecode($parts[1]);
+        if ($payload === null) {
+            return null;
+        }
+
+        $claims = json_decode($payload, true);
+        return is_array($claims) ? $claims : null;
+    }
+
+    /** @param array<string, mixed> $claims */
+    private function claimsAreValid(array $claims, string $expectedNonce): bool
+    {
+        if ((string) ($claims['aud'] ?? '') !== $this->clientId()) {
+            $this->log('id_token abgelehnt: aud stimmt nicht.');
+            return false;
+        }
+
+        $issuer = (string) ($claims['iss'] ?? '');
+        if ($this->tenantId() !== '' && strpos($issuer, $this->tenantId()) === false) {
+            $this->log('id_token abgelehnt: iss/tenant stimmt nicht.');
+            return false;
+        }
+
+        $exp = (int) ($claims['exp'] ?? 0);
+        if ($exp > 0 && $exp < (time() - 60)) {
+            $this->log('id_token abgelehnt: abgelaufen.');
+            return false;
+        }
+
+        if ($expectedNonce !== '' && !hash_equals($expectedNonce, (string) ($claims['nonce'] ?? ''))) {
+            $this->log('id_token abgelehnt: nonce stimmt nicht.');
+            return false;
+        }
+
+        return true;
+    }
+
+    private function isAllowed(string $email): bool
+    {
+        $allowed = (array) ($this->entra['allowed_users'] ?? []);
+        foreach ($allowed as $entry) {
+            if (is_string($entry) && $entry !== '' && hash_equals(strtolower(trim($entry)), $email)) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
+    private function tenantId(): string
+    {
+        return trim((string) ($this->entra['tenant_id'] ?? ''));
+    }
+
+    private function clientId(): string
+    {
+        return trim((string) ($this->entra['client_id'] ?? ''));
+    }
+
+    private function redirectUri(): string
+    {
+        return trim((string) ($this->entra['redirect_uri'] ?? ''));
+    }
+
+    private function log(string $message): void
+    {
+        Bootstrap::log('portal', $message);
+    }
+
+    private static function base64UrlEncode(string $data): string
+    {
+        return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
+    }
+
+    private static function base64UrlDecode(string $data): ?string
+    {
+        $remainder = strlen($data) % 4;
+        if ($remainder !== 0) {
+            $data .= str_repeat('=', 4 - $remainder);
+        }
+
+        $decoded = base64_decode(strtr($data, '-_', '+/'), true);
+        return $decoded === false ? null : $decoded;
+    }
+}

+ 44 - 0
src/storage/jsonstore.php

@@ -184,6 +184,50 @@ final class JsonStore
         return $list;
     }
 
+    /**
+     * Record that the hand-signed minor consent form for a submission has been received.
+     * Atomic under the per-email lock: `newly_marked` is true only for the call that
+     * actually set the marker, so the caller can guarantee the notification email is sent
+     * exactly once. Returns null when the submission does not exist.
+     *
+     * @param array{received_by?: string, received_by_email?: string} $meta
+     * @return array{submission: array<string, mixed>, newly_marked: bool}|null
+     */
+    public function markSignedFormReceived(string $applicationKey, array $meta): ?array
+    {
+        $submission = $this->getSubmissionByKey($applicationKey);
+        if ($submission === null) {
+            return null;
+        }
+
+        $email = (string) ($submission['email'] ?? '');
+        if ($email === '') {
+            return null;
+        }
+
+        return $this->withEmailLock($email, function () use ($email, $meta): ?array {
+            $path = $this->submissionPath($email);
+            if (!is_file($path)) {
+                return null;
+            }
+
+            $current = $this->readJsonFile($path);
+            if (isset($current['signed_form_received']) && is_array($current['signed_form_received'])) {
+                return ['submission' => $current, 'newly_marked' => false];
+            }
+
+            $current['signed_form_received'] = [
+                'received_at' => date('c'),
+                'received_by' => (string) ($meta['received_by'] ?? ''),
+                'received_by_email' => (string) ($meta['received_by_email'] ?? ''),
+            ];
+
+            $this->writeJsonFile($path, $current);
+
+            return ['submission' => $current, 'newly_marked' => true];
+        });
+    }
+
     public function deleteSubmissionByKey(string $applicationKey): void
     {
         $safeKey = $this->normalizeApplicationKey($applicationKey);