Zum Hauptinhalt springen

Authentifizierung

Postbox nutzt Laravel Fortify fuer Passwort-basierte Authentifizierung, Google OAuth via Socialite und Laravel Sanctum fuer die Collector API.

Laravel Fortify (Web)

Die Web-Authentifizierung basiert auf Laravel Fortify mit Custom Actions und Views. Optionale Zwei-Faktor-Authentifizierung (TOTP) ist integriert.

Location: app/Providers/FortifyServiceProvider.php, config/fortify.php

Login-Flow

graph TD
A[User klickt Anmelden] --> B[GET /login]
B --> C[Blade View: auth.login]
C --> D[POST /login]
D --> E{Fortify Authenticate}
E -->|Erfolg| F{2FA aktiv?}
E -->|Fehler| G[Rate-Limit Check]
G -->|Max 5/min| H[429 Too Many Requests]
G -->|OK| C
F -->|Ja| I[GET /two-factor-challenge]
I --> J[User gibt TOTP-Code ein]
J --> K{Code gueltig?}
K -->|Ja| L[Redirect /dashboard]
K -->|Nein| I
F -->|Nein| L

Registrierung

graph TD
A[GET /register] --> B[Blade View: auth.register]
B --> C[POST /register]
C --> D[CreateNewUser Action]
D --> E{Validierung}
E -->|Fehler| B
E -->|OK| F[User erstellen + Passwort hashen]
F --> G{Newsletter-Checkbox?}
G -->|Ja| H[Token generieren + Confirmation-Mail queuen]
G -->|Nein| I[Auto-Login]
H --> I
I --> J[Redirect /overview]

Location: app/Actions/Fortify/CreateNewUser.php

Validierungsregeln:

  • name: required, max 255
  • email: required, unique in users, max 255
  • password: required, Fortify default rules (min 8, mixed case, numbers, symbols), confirmed
  • newsletter: nullable, boolean
  • privacy_accepted: accepted (Pflicht)
  • terms_accepted: accepted (Pflicht)

Pflicht-Checkboxen fuer Datenschutzerklaerung und Nutzungsbedingungen auf der Registrierungsseite. Beide muessen akzeptiert werden, sonst schlaegt die Validierung fehl.

Datenbank-Spalten (users):

SpalteTypBeschreibung
privacy_accepted_attimestamp, nullableZeitpunkt der Datenschutz-Zustimmung
terms_accepted_attimestamp, nullableZeitpunkt der AGB-Zustimmung

Location: database/migrations/2026_02_15_000002_add_legal_acceptance_fields_to_users_table.php

UI-Elemente:

  • Zwei Pflicht-Checkboxen zwischen Newsletter-Checkbox und Turnstile-Widget
  • Links in den Labels oeffnen Alpine.js-Modals mit CMS-Seiteninhalt (konfigurierbar via config('postbox.legal.privacy_page_id') und config('postbox.legal.terms_page_id'))
  • Ohne konfigurierte Page-ID: reiner Text ohne Modal-Link
  • "Gelesen und akzeptieren"-Button im Modal checkt automatisch die Checkbox
  • Google-OAuth-Hinweis unter dem Button: "Mit der Registrierung ueber Google akzeptierst du unsere Nutzungsbedingungen und Datenschutzerklaerung"

Fehlermeldungen:

  • privacy_accepted.accepted → "Bitte akzeptiere die Datenschutzerklaerung."
  • terms_accepted.accepted → "Bitte akzeptiere die Nutzungsbedingungen."

Config:

LEGAL_PRIVACY_PAGE_ID=0    # CMS Page-ID fuer Datenschutzerklaerung
LEGAL_TERMS_PAGE_ID=0 # CMS Page-ID fuer Nutzungsbedingungen

Location: config/postbox.php (Abschnitt legal), app/Providers/FortifyServiceProvider.php (View-Data-Loading)


Newsletter Double-Opt-In

Optionale Newsletter-Checkbox auf der Registrierungsseite. Bei Aktivierung wird ein Double-Opt-In-Flow gestartet.

graph TD
A[Registrierung mit Newsletter-Checkbox] --> B[CreateNewUser Action]
B --> C[64-Zeichen Token generieren]
C --> D[newsletter_token + expires_at speichern]
D --> E[NewsletterConfirmation Mail queuen]
E --> F[User erhaelt E-Mail]
F --> G[Klick auf signierten Link]
G --> H[NewsletterConfirmationController]
H --> I{Token gueltig + nicht abgelaufen?}
I -->|Ja| J[newsletter_opted_in_at = now]
I -->|Nein| K[Fehler-Redirect]
J --> L[Token loeschen + Redirect Dashboard]

Datenbank-Spalten (users):

SpalteTypBeschreibung
newsletter_opted_in_attimestamp, nullableZeitpunkt der Bestaetigung
newsletter_tokenvarchar(64), nullableHMAC-Token fuer E-Mail-Verifikation
newsletter_token_expires_attimestamp, nullableToken-Ablauf (7 Tage nach Registrierung)

Location: database/migrations/2026_02_15_000001_add_newsletter_fields_to_users_table.php

Helper-Methoden (User Model):

  • hasNewsletterSubscription() — Prueft ob newsletter_opted_in_at IS NOT NULL
  • hasNewsletterPending() — Prueft ob Token existiert und newsletter_opted_in_at IS NULL

Confirmation-Mail:

EigenschaftWert
Queueemails
Tries3
Backoff[30, 120, 300] (30s, 2min, 5min)
Subject"Bitte bestätige dein Newsletter-Abo"
Templateresources/views/emails/newsletter-confirmation.blade.php

Location: app/Mail/NewsletterConfirmation.php

Confirmation Controller:

Invokable Controller, der den signierten Link validiert. Prueft Token-Match und Ablaufdatum. Bei Erfolg wird newsletter_opted_in_at gesetzt und Token geloescht.

Location: app/Http/Controllers/Auth/NewsletterConfirmationController.php

Admin-Sicht: Der Newsletter-Status wird in /admin/users angezeigt (bestätigt ✅, ausstehend ⏳). Filter: subscribed, pending, none.


Passwort-Reset

graph TD
A[GET /forgot-password] --> B[E-Mail eingeben]
B --> C[POST /forgot-password]
C --> D[Reset-Token in password_reset_tokens]
D --> E[E-Mail mit Reset-Link]
E --> F[GET /reset-password?token=...]
F --> G[Neues Passwort eingeben]
G --> H[POST /reset-password]
H --> I[ResetUserPassword Action]
I --> J[Redirect /login]

Location: app/Actions/Fortify/ResetUserPassword.php

Passwort aendern (Settings)

Location: app/Actions/Fortify/UpdateUserPassword.php

  • Erfordert current_password (Fehler: "Das aktuelle Passwort ist falsch.")
  • Neue Passwort-Validierung identisch zur Registrierung

Rate-Limiting

Login-Versuche sind auf 5 pro Minute pro E-Mail+IP limitiert:

// app/Providers/FortifyServiceProvider.php
RateLimiter::for('login', function (Request $request) {
$throttleKey = Str::transliterate(
Str::lower($request->input(Fortify::username())).'|'.$request->ip()
);
return Limit::perMinute(5)->by($throttleKey);
});

Fortify Views

Alle Views sind als Blade-Templates registriert:

View-CallbackBlade TemplateBeschreibung
loginauth.loginLogin-Formular
registerauth.registerRegistrierung
requestPasswordResetLinkauth.forgot-passwordPasswort-Reset anfordern
resetPasswordauth.reset-passwordNeues Passwort setzen
twoFactorChallengeauth.two-factor-challengeTOTP-Code Eingabe
confirmPasswordauth.confirm-passwordPasswort-Bestaetigung (fuer 2FA)

Redirects

AktionRedirect-Ziel
Login/dashboard
Registration/overview
Logout/
Password Reset/login

.env-Variablen

# Fortify nutzt Standard-Laravel-Auth, keine speziellen Env-Variablen noetig.
# Session/Cookie-Config ueber Standard-Laravel-Env:
SESSION_DRIVER=database
SESSION_LIFETIME=120

Aktivierte Features

// config/fortify.php
'features' => [
Features::registration(),
Features::resetPasswords(),
Features::updatePasswords(),
Features::twoFactorAuthentication([
'confirmPassword' => true,
]),
],

Google OAuth (Socialite)

Optionaler Login via Google OAuth 2.0, parallel zur Passwort-Authentifizierung.

Location: app/Http/Controllers/Auth/SocialiteController.php

OAuth-Flow

graph TD
A[User klickt Google Login] --> B[GET /auth/google]
B --> C[SocialiteController::redirect]
C --> D[Redirect zu Google Consent]
D --> E[User authentifiziert sich bei Google]
E --> F[Google Callback]
F --> G[GET /auth/google/callback]
G --> H[SocialiteController::callback]
H --> I{User existiert?}
I -->|Ja| J[Login + Avatar-Update falls leer]
I -->|Nein| K[Neuen User erstellen]
K --> L[email_verified_at = now]
J --> M[Redirect /dashboard]
L --> M

Besonderheiten

  • Neue User: E-Mail wird automatisch als verifiziert markiert (email_verified_at = now())
  • Avatar: Wird nur beim Erstlogin uebernommen (kein Ueberschreiben bestehender Avatare)
  • Remember-Me: OAuth-Login setzt immer remember: true
  • Fehlerbehandlung: OAuth-Fehler werden an Flare/Nightwatch gemeldet, User wird zu /login mit Fehlermeldung weitergeleitet

.env-Variablen

GOOGLE_CLIENT_ID=xxx.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=GOCSPX-xxx
GOOGLE_REDIRECT_URI=/auth/google/callback

Location: config/services.php (Abschnitt google)


Zwei-Faktor-Authentifizierung (2FA)

TOTP-basierte 2FA ueber Fortify mit QR-Code-Setup, Code-Bestaetigung und Recovery Codes.

Location: app/Livewire/Settings/TwoFactor.php

Setup-Flow

stateDiagram-v2
[*] --> Deaktiviert
Deaktiviert --> Setup: enableTwoFactor()
Setup --> Bestaetigt: confirmTwoFactor(code)
Setup --> Deaktiviert: Abbruch
Bestaetigt --> RecoveryCodes: Codes anzeigen
RecoveryCodes --> Bestaetigt: regenerateRecoveryCodes()
Bestaetigt --> Deaktiviert: disableTwoFactor()

Ablauf

  1. User klickt "Aktivieren" → Passwort-Bestaetigung erforderlich (confirmPassword: true in Fortify Config)
  2. EnableTwoFactorAuthentication Action generiert Secret + Recovery Codes
  3. QR-Code (SVG) und Setup-Key werden angezeigt
  4. User scannt QR in Authenticator-App, gibt 6-stelligen Code ein
  5. ConfirmTwoFactorAuthentication Action validiert Code, setzt two_factor_confirmed_at
  6. 8 Recovery Codes werden angezeigt (einmalig, danach nur durch Regeneration)

Datenbank-Spalten

SpalteTypBeschreibung
two_factor_secrettext, nullableVerschluesselter TOTP-Secret
two_factor_recovery_codestext, nullableJSON-Array mit 8 Backup-Codes
two_factor_confirmed_attimestamp, nullableBestaetigung des 2FA-Setups

Location: database/migrations/2026_02_12_203258_add_two_factor_columns_to_users_table.php

Toast-Meldungen

AktionTypMeldung
Enablesuccess"Zwei-Faktor-Authentifizierung aktiviert"
Confirm (Fehler)error"Der eingegebene Code ist ungueltig."
Regeneratesuccess"Neue Recovery Codes generiert"
Disablesuccess"Zwei-Faktor-Authentifizierung deaktiviert"

Admin-Gate-System

Admin-Zugriff wird ueber ein Gate gesteuert, das auf einer Whitelist von E-Mail-Adressen basiert.

Location: app/Providers/AppServiceProvider.php, config/postbox.php

Konfiguration

Admin-E-Mails werden kommasepariert in der .env definiert:

POSTBOX_ADMIN_EMAILS="admin@example.com, second@example.com"

Die Config normalisiert die Eingabe (Trimming, Lowercase, Quotes entfernen):

// config/postbox.php
'admin_emails' => array_values(array_filter(array_map(function ($email) {
$email = trim((string) $email);
$email = trim($email, " \t\n\r\0\x0B\"'");
return Str::lower($email);
}, explode(',', (string) env('POSTBOX_ADMIN_EMAILS', ''))))),

Gate-Definitionen

Alle Gates delegieren an User::isAdmin():

// app/Providers/AppServiceProvider.php
Gate::define('admin', fn (?User $user) => $user?->isAdmin() ?? false);
Gate::define('viewPulse', fn (?User $user) => $user?->isAdmin() ?? false);
Gate::define('viewVantage', fn (?User $user) => $user?->isAdmin() ?? false);
Gate::define('viewLogViewer', fn (?User $user) => $user?->isAdmin() ?? false);

Gates im Detail

GateVerwendungSchuetzt
admincan:admin Middleware, Gate::allows('admin'), $this->authorizeAdmin()Alle /admin/* Routen, Admin-Aktionen in Livewire
viewPulseLaravel Pulse Authorization/admin/pulse (eingebettet im Admin-Layout)
viewVantageVantage Package Authorization/admin/vantage (eingebettet im Admin-Layout)
viewLogViewerLog Viewer Authorization/admin/logs

Sicherheits-Massnahmen

Fuer nicht-autorisierte Zugriffe auf Pulse, Vantage und Log Viewer wird ein 404 statt 403 zurueckgegeben, um die Existenz der Endpunkte zu verbergen:

// bootstrap/app.php
$exceptions->render(function (AuthorizationException $e, Request $request) {
if ($request->is('pulse') || $request->is('pulse/*')) {
return abort(404);
}
if ($request->is('vantage') || $request->is('vantage/*')) {
return abort(404);
}
if ($request->is('admin/log-viewer') || $request->is('admin/log-viewer/*')) {
return abort(404);
}
});

Admin-Check in Livewire Components

// In Livewire Component Methoden:
$this->authorizeAdmin(); // Wirft 403 wenn kein Admin

// Oder manuell:
if (! Gate::allows('admin')) {
abort(403);
}

Sanctum Token Auth (Collector API)

Externe Collector-Clients (Instagram-Scraper) authentifizieren sich ueber Laravel Sanctum Bearer Tokens gegen die REST API.

Location: routes/api.php, app/Console/Commands/CollectorToken.php

Token erstellen

php artisan collector:token mein-collector

Gibt ein Sanctum-Token aus, das der Collector als Bearer Token verwendet. Der Token wird einem CollectorClient-Model zugeordnet.

API-Authentifizierung

curl -X POST https://app.postbox.so/api/collector/jobs/lease \
-H "Authorization: Bearer <sanctum-token>" \
-H "Accept: application/json"

Rate-Limiting

Die Collector API wird mit 600 Requests/Minute pro Client limitiert:

// app/Providers/AppServiceProvider.php
RateLimiter::for('collector', function (Request $request): Limit {
return Limit::perMinute(600)
->by((string) ($request->user()?->getAuthIdentifier() ?? $request->ip()));
});

API-Endpunkte

MethodURLBeschreibung
POST/api/collector/jobs/leaseNaechsten Job leasen (5-Minuten TTL)
GET/api/collector/jobs/statsJob-Statistiken
POST/api/collector/jobs/{job}/completeErgebnis melden
POST/api/collector/jobs/{job}/failFehler melden
POST/api/collectors/heartbeatHeartbeat senden

CollectorJobPolicy

Die Policy stellt sicher, dass ein Collector nur Jobs verwalten kann, die ihm geleast wurden:

Location: app/Policies/CollectorJobPolicy.php

// Nur der Client, dem der Job geleast wurde, darf complete/fail aufrufen.
public function manage(CollectorClient $client, CollectorJob $job): bool
{
return $job->collector_client_id === $client->id;
}

User-Blocking

Admins koennen User account-weit sperren. Die Sperre wird an drei Stellen durchgesetzt:

  1. CheckBlockedUser Middleware: Prueft bei jedem authentifizierten Web-Request $user->isBlocked(). Gesperrte User werden sofort ausgeloggt (Session invalidiert, remember_token geloescht).
  2. Fortify Login: Custom authenticateUsing()-Callback prueft vor der Passwort-Validierung, ob der User gesperrt ist. Fehlermeldung ist generisch ("Diese Zugangsdaten sind ungueltig."), um Enumeration zu verhindern.
  3. Google OAuth: SocialiteController::callback() prueft nach OAuth-Callback, ob der User gesperrt ist.

Datenbank-Spalten (users)

SpalteTypBeschreibung
blocked_attimestamp, nullableZeitpunkt der Sperre
blocked_bybigint (FK), nullableSperrender Admin
block_reasonvarchar(500), nullableSperrgrund

Audit-Log (user_blocks)

SpalteTypBeschreibung
user_idbigint (FK)Betroffener User
admin_idbigint (FK)Handelnder Admin
actionstringblocked oder unblocked
reasontext, nullableGrund
created_atdatetimeZeitpunkt

Location: app/Http/Middleware/CheckBlockedUser.php, app/Models/UserBlock.php, database/migrations/2026_02_23_100001_add_user_blocking_fields.php


Session-Expiry

Abgelaufene Sessions (HTTP 419) werden benutzerfreundlich behandelt:

  • Klassische Requests: Redirect zur Login-Seite mit Hinweis-Nachricht
  • Livewire-Requests: 419-Status zurueckgeben (Client-seitiger Redirect via JavaScript)
// bootstrap/app.php
$exceptions->render(function (HttpExceptionInterface $e, Request $request) {
if ($e->getStatusCode() !== 419) {
return;
}
$message = 'Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.';
// ...
});

Cloudflare Turnstile (Bot-Schutz)

Login und Register nutzen Cloudflare Turnstile als unsichtbares CAPTCHA zum Bot-Schutz. Sichtbares Widget wird ueber dem Submit-Button angezeigt, nur fuer Gäste geladen.

Location: app/Services/Security/TurnstileValidator.php, config/turnstile.php

Architektur

graph TD
A[User oeffnet Login/Register] --> B{Turnstile enabled?}
B -->|Nein| C[Normaler Flow]
B -->|Ja| D[Turnstile Widget rendern]
D --> E[User loest Challenge]
E --> F[Token im Hidden-Field]
F --> G[POST /login oder /register]
G --> H[TurnstileValidator::validate]
H --> I{Cloudflare API erreichbar?}
I -->|Ja| J{Token gueltig?}
I -->|Nein| K[Fail-Open: Request erlaubt]
J -->|Ja| L[Fortify Authentifizierung]
J -->|Nein| M[ValidationException]

Integration

  • Login: Validierung in FortifyServiceProvider::authenticateUsing() VOR User-Lookup
  • Register: Validierung in CreateNewUser::create() VOR Form-Validierung
  • Kontaktformular: Eigene Turnstile-Config in config/contact.php (separater Site/Secret Key moeglich)

.env-Variablen

TURNSTILE_ENABLED=true
TURNSTILE_SITE_KEY=0x4AAAAAAAxxx
TURNSTILE_SECRET_KEY=0x4AAAAAAAxxx

Fail-Open-Verhalten

Der TurnstileValidator implementiert Fail-Open: Wenn die Cloudflare-API nicht erreichbar ist (Timeout, Netzwerkfehler), wird der Request durchgelassen. Rate-Limiting und Honeypot (beim Kontaktformular) greifen als Fallback.

Debug-Logging

Der Validator loggt zwei Szenarien als Log::warning():

  1. Fehlende Daten: Token oder Secret-Key fehlen — geloggt mit has_secret, has_token, ip.
  2. Fehlgeschlagene Validierung: Cloudflare-API antwortet mit success: false — geloggt mit http_status, success, error_codes (Cloudflare Error-Codes), ip.

Rate-Limiter Uebersicht

NameLimitAngewendet auf
login5/min pro E-Mail+IPFortify Login
collector600/min pro ClientCollector API Endpunkte
ai-detectionKonfigurierbar (Default: 15/min)Gemini AI Detection Jobs
tag-consolidationKonfigurierbar (Default: 10/min)Tag Consolidation API Calls
youtube-research2/minYouTube Search-API-Jobs

Auth-Migration (WorkOS → Fortify)

Die Migration von WorkOS SSO auf Fortify erfolgte am 2026-02-12.

Location: database/migrations/2026_02_23_000001_migrate_auth_from_workos_to_fortify.php

Schema-Aenderungen

SpalteAenderungGrund
passwordNeu (nullable)Fortify-Registration, nullable fuer bestehende OAuth-User
workos_idNullable gemachtNeue User ohne WorkOS
avatarNullable gemachtFortify liefert kein Avatar automatisch

Neue Tabelle

TabelleSpaltenZweck
password_reset_tokensemail (PK), token, created_atFortify Passwort-Reset Flow