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 255email: required, unique in users, max 255password: required, Fortify default rules (min 8, mixed case, numbers, symbols), confirmednewsletter: nullable, booleanprivacy_accepted: accepted (Pflicht)terms_accepted: accepted (Pflicht)
Legal-Checkboxen (Datenschutz + AGB)
Pflicht-Checkboxen fuer Datenschutzerklaerung und Nutzungsbedingungen auf der Registrierungsseite. Beide muessen akzeptiert werden, sonst schlaegt die Validierung fehl.
Datenbank-Spalten (users):
| Spalte | Typ | Beschreibung |
|---|---|---|
privacy_accepted_at | timestamp, nullable | Zeitpunkt der Datenschutz-Zustimmung |
terms_accepted_at | timestamp, nullable | Zeitpunkt 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')undconfig('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):
| Spalte | Typ | Beschreibung |
|---|---|---|
newsletter_opted_in_at | timestamp, nullable | Zeitpunkt der Bestaetigung |
newsletter_token | varchar(64), nullable | HMAC-Token fuer E-Mail-Verifikation |
newsletter_token_expires_at | timestamp, nullable | Token-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 obnewsletter_opted_in_at IS NOT NULLhasNewsletterPending()— Prueft ob Token existiert undnewsletter_opted_in_at IS NULL
Confirmation-Mail:
| Eigenschaft | Wert |
|---|---|
| Queue | emails |
| Tries | 3 |
| Backoff | [30, 120, 300] (30s, 2min, 5min) |
| Subject | "Bitte bestätige dein Newsletter-Abo" |
| Template | resources/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-Callback | Blade Template | Beschreibung |
|---|---|---|
login | auth.login | Login-Formular |
register | auth.register | Registrierung |
requestPasswordResetLink | auth.forgot-password | Passwort-Reset anfordern |
resetPassword | auth.reset-password | Neues Passwort setzen |
twoFactorChallenge | auth.two-factor-challenge | TOTP-Code Eingabe |
confirmPassword | auth.confirm-password | Passwort-Bestaetigung (fuer 2FA) |
Redirects
| Aktion | Redirect-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
/loginmit 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
- User klickt "Aktivieren" → Passwort-Bestaetigung erforderlich (
confirmPassword: truein Fortify Config) EnableTwoFactorAuthenticationAction generiert Secret + Recovery Codes- QR-Code (SVG) und Setup-Key werden angezeigt
- User scannt QR in Authenticator-App, gibt 6-stelligen Code ein
ConfirmTwoFactorAuthenticationAction validiert Code, setzttwo_factor_confirmed_at- 8 Recovery Codes werden angezeigt (einmalig, danach nur durch Regeneration)
Datenbank-Spalten
| Spalte | Typ | Beschreibung |
|---|---|---|
two_factor_secret | text, nullable | Verschluesselter TOTP-Secret |
two_factor_recovery_codes | text, nullable | JSON-Array mit 8 Backup-Codes |
two_factor_confirmed_at | timestamp, nullable | Bestaetigung des 2FA-Setups |
Location: database/migrations/2026_02_12_203258_add_two_factor_columns_to_users_table.php
Toast-Meldungen
| Aktion | Typ | Meldung |
|---|---|---|
| Enable | success | "Zwei-Faktor-Authentifizierung aktiviert" |
| Confirm (Fehler) | error | "Der eingegebene Code ist ungueltig." |
| Regenerate | success | "Neue Recovery Codes generiert" |
| Disable | success | "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
| Gate | Verwendung | Schuetzt |
|---|---|---|
admin | can:admin Middleware, Gate::allows('admin'), $this->authorizeAdmin() | Alle /admin/* Routen, Admin-Aktionen in Livewire |
viewPulse | Laravel Pulse Authorization | /admin/pulse (eingebettet im Admin-Layout) |
viewVantage | Vantage Package Authorization | /admin/vantage (eingebettet im Admin-Layout) |
viewLogViewer | Log 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
| Method | URL | Beschreibung |
|---|---|---|
| POST | /api/collector/jobs/lease | Naechsten Job leasen (5-Minuten TTL) |
| GET | /api/collector/jobs/stats | Job-Statistiken |
| POST | /api/collector/jobs/{job}/complete | Ergebnis melden |
| POST | /api/collector/jobs/{job}/fail | Fehler melden |
| POST | /api/collectors/heartbeat | Heartbeat 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:
CheckBlockedUserMiddleware: Prueft bei jedem authentifizierten Web-Request$user->isBlocked(). Gesperrte User werden sofort ausgeloggt (Session invalidiert,remember_tokengeloescht).- 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. - Google OAuth:
SocialiteController::callback()prueft nach OAuth-Callback, ob der User gesperrt ist.
Datenbank-Spalten (users)
| Spalte | Typ | Beschreibung |
|---|---|---|
blocked_at | timestamp, nullable | Zeitpunkt der Sperre |
blocked_by | bigint (FK), nullable | Sperrender Admin |
block_reason | varchar(500), nullable | Sperrgrund |
Audit-Log (user_blocks)
| Spalte | Typ | Beschreibung |
|---|---|---|
user_id | bigint (FK) | Betroffener User |
admin_id | bigint (FK) | Handelnder Admin |
action | string | blocked oder unblocked |
reason | text, nullable | Grund |
created_at | datetime | Zeitpunkt |
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():
- Fehlende Daten: Token oder Secret-Key fehlen — geloggt mit
has_secret,has_token,ip. - Fehlgeschlagene Validierung: Cloudflare-API antwortet mit
success: false— geloggt mithttp_status,success,error_codes(Cloudflare Error-Codes),ip.
Rate-Limiter Uebersicht
| Name | Limit | Angewendet auf |
|---|---|---|
login | 5/min pro E-Mail+IP | Fortify Login |
collector | 600/min pro Client | Collector API Endpunkte |
ai-detection | Konfigurierbar (Default: 15/min) | Gemini AI Detection Jobs |
tag-consolidation | Konfigurierbar (Default: 10/min) | Tag Consolidation API Calls |
youtube-research | 2/min | YouTube 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
| Spalte | Aenderung | Grund |
|---|---|---|
password | Neu (nullable) | Fortify-Registration, nullable fuer bestehende OAuth-User |
workos_id | Nullable gemacht | Neue User ohne WorkOS |
avatar | Nullable gemacht | Fortify liefert kein Avatar automatisch |
Neue Tabelle
| Tabelle | Spalten | Zweck |
|---|---|---|
password_reset_tokens | email (PK), token, created_at | Fortify Passwort-Reset Flow |