Optimierung Phase 0: Korrektheit & Security
Konsolidierter Entwicklungsplan aus den zwei Analyse-Dokumenten plus
Umsetzung der Phase-0-Massnahmen (Korrektheit/Security, je mit Tests):
0.1 BasicAuth: timing-sicherer Vergleich (hash_equals, beide Felder ohne
Short-Circuit); Enabled-Check zur Laufzeit via config('auth.basic.*')
statt env() im Bootstrap-Closure (greift jetzt unter config:cache);
Config-Block + .env.example. Fund: auth.basic.* war nie als Config
definiert, Gate war faktisch unkonfigurierbar.
0.2 Legacy-Invoice-"latest"-Abfrage zentralisiert (LegacyInvoice::
scopeLatestFirst + User::latestLegacyInvoice); beide Consumer N+1-frei
umgestellt; unterschiedliches null-Verhalten bewusst erhalten.
0.3 Payment-Option-Eligibility vereinheitlicht (period-aware, strikt):
UserPaymentOption::scopeActiveNow/grandfatheredNow + User::
hasActivePaymentOption; hasActiveBooking/hasUnlimitedPressReleaseQuota
und ApiAccessEligibilityService darauf umgestellt; $hasGrandfathered-
Mislabel behoben.
0.4 Theme->Portal-Vokabular: totes, projektfremdes ThemeMiddleware
geloescht; Mapping in Portal::fromTheme() zentralisiert; fremden
b2in-Default im ThemeServiceProvider auf Portal-Default korrigiert.
0.5 InvoiceNumberGenerator: Erst-Insert race-sicher via insertOrIgnore
(Unique-Index auf circle existiert bereits). CreditWalletService war
bereits abgesichert (Unique-Index + createOrFirst).
Tests: 4 neue Dateien, 5 bestehende auf gueltiges grandfathered_until
angepasst. Volle Suite gruen (793 passed, 1 skipped).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
39f0af7d0b
commit
a82964df2b
27 changed files with 990 additions and 133 deletions
|
|
@ -8,6 +8,21 @@ enum Portal: string
|
|||
case Businessportal24 = 'businessportal24';
|
||||
case Both = 'both';
|
||||
|
||||
/**
|
||||
* Mappt ein Domain-Theme (aus config/domains.php, gesetzt vom
|
||||
* ThemeServiceProvider) auf das zugehörige Portal. Admin-/Hub-Themes
|
||||
* (portal, pressekonto, main, …) liefern null → kein Portal-Filter.
|
||||
* Single Source of Truth für die Theme→Portal-Auflösung.
|
||||
*/
|
||||
public static function fromTheme(?string $theme): ?self
|
||||
{
|
||||
return match ($theme) {
|
||||
'presseecho' => self::Presseecho,
|
||||
'businessportal24' => self::Businessportal24,
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
|
|
|
|||
|
|
@ -15,6 +15,11 @@ class BasicAuthMiddleware
|
|||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
// Gate global deaktiviert (z. B. Produktion/Tests)
|
||||
if (! config('auth.basic.enabled', true)) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
// Skip Basic Auth für Livewire-Requests komplett
|
||||
// Diese sind bereits durch Laravel Session/CSRF geschützt
|
||||
$path = $request->path();
|
||||
|
|
@ -38,11 +43,20 @@ class BasicAuthMiddleware
|
|||
return $next($request);
|
||||
}
|
||||
|
||||
// Credentials from .env file
|
||||
// Zugangsdaten aus der Config (gespeist aus .env). Ist nichts
|
||||
// konfiguriert, wird der Gate nicht erzwungen.
|
||||
$user = config('auth.basic.user');
|
||||
$pass = config('auth.basic.password');
|
||||
|
||||
if ($request->getUser() != $user || $request->getPassword() != $pass) {
|
||||
if (($user === null || $user === '') && ($pass === null || $pass === '')) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
// Timing-sicherer Vergleich beider Felder (kein Short-Circuit).
|
||||
$userValid = hash_equals((string) $user, (string) $request->getUser());
|
||||
$passValid = hash_equals((string) $pass, (string) $request->getPassword());
|
||||
|
||||
if (! $userValid || ! $passValid) {
|
||||
return response('Unauthorized.', 401, ['WWW-Authenticate' => 'Basic']);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -44,13 +44,8 @@ class SetCurrentPortal
|
|||
}
|
||||
}
|
||||
|
||||
// Domain-basierte Auflösung via ThemeServiceProvider
|
||||
$theme = config('app.theme', 'main');
|
||||
|
||||
return match ($theme) {
|
||||
'presseecho' => Portal::Presseecho,
|
||||
'businessportal24' => Portal::Businessportal24,
|
||||
default => null, // Admin/Portal-Domain → kein automatischer Filter
|
||||
};
|
||||
// Domain-basierte Auflösung via ThemeServiceProvider; Mapping zentral
|
||||
// im Portal-Enum (Admin/Portal-Domain → null, kein automatischer Filter).
|
||||
return Portal::fromTheme(config('app.theme', 'main'));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,57 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class ThemeMiddleware
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param Closure(Request): (Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$host = $request->getHost();
|
||||
$path = $request->path();
|
||||
|
||||
// Theme-Switching über Subdomains
|
||||
if (str_contains($host, 'b2in')) {
|
||||
config(['app.theme' => 'b2in']);
|
||||
} elseif (str_contains($host, 'b2a') || str_contains($host, 'bridges2america')) {
|
||||
config(['app.theme' => 'b2a']);
|
||||
} elseif (str_contains($host, 'stileigentum')) {
|
||||
config(['app.theme' => 'stileigentum']);
|
||||
} elseif (str_contains($host, 'style2own')) {
|
||||
config(['app.theme' => 'style2own']);
|
||||
}
|
||||
|
||||
// Theme-Switching über URL-Parameter (für Testing)
|
||||
if ($request->has('theme')) {
|
||||
$theme = $request->get('theme');
|
||||
if (in_array($theme, ['b2in', 'b2a', 'stileigentum', 'style2own'])) {
|
||||
config(['app.theme' => $theme]);
|
||||
}
|
||||
}
|
||||
|
||||
// Theme-Switching über Pfade (für lokale Entwicklung ohne Domain-Setup)
|
||||
if (str_starts_with($path, 'b2in/')) {
|
||||
config(['app.theme' => 'b2in']);
|
||||
$request->server->set('REQUEST_URI', '/'.substr($path, 5)); // Entferne 'b2in/' vom Pfad
|
||||
} elseif (str_starts_with($path, 'b2a/') || str_starts_with($path, 'bridges2america/')) {
|
||||
config(['app.theme' => 'b2a']);
|
||||
$request->server->set('REQUEST_URI', '/'.substr($path, 4)); // Entferne 'b2a/' vom Pfad
|
||||
} elseif (str_starts_with($path, 'stileigentum/')) {
|
||||
config(['app.theme' => 'stileigentum']);
|
||||
$request->server->set('REQUEST_URI', '/'.substr($path, 13)); // Entferne 'stileigentum/' vom Pfad
|
||||
} elseif (str_starts_with($path, 'style2own/')) {
|
||||
config(['app.theme' => 'style2own']);
|
||||
$request->server->set('REQUEST_URI', '/'.substr($path, 10)); // Entferne 'style2own/' vom Pfad
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
namespace App\Models;
|
||||
|
||||
use App\Enums\Portal;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
|
|
@ -52,4 +53,13 @@ class LegacyInvoice extends Model
|
|||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Neueste Rechnung zuerst: nach Rechnungsdatum, dann ID als Tiebreaker.
|
||||
* Single Source of Truth für die „latest legacy invoice"-Ordnung.
|
||||
*/
|
||||
public function scopeLatestFirst(Builder $query): Builder
|
||||
{
|
||||
return $query->orderByDesc('invoice_date')->orderByDesc('id');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ namespace App\Models;
|
|||
use App\Enums\Portal;
|
||||
use App\Enums\RegistrationType;
|
||||
use App\Enums\Tier;
|
||||
use App\Enums\UserPaymentOptionStatus;
|
||||
use Database\Factories\UserFactory;
|
||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
|
|
@ -188,12 +187,17 @@ class User extends Authenticatable implements MustVerifyEmail
|
|||
return $this->cachedHasUnlimitedQuota = true;
|
||||
}
|
||||
|
||||
return $this->cachedHasUnlimitedQuota = $this->userPaymentOptions()
|
||||
->whereIn('status', [
|
||||
UserPaymentOptionStatus::Active->value,
|
||||
UserPaymentOptionStatus::Grandfathered->value,
|
||||
])
|
||||
->exists();
|
||||
return $this->cachedHasUnlimitedQuota = $this->hasActivePaymentOption();
|
||||
}
|
||||
|
||||
/**
|
||||
* Period-aware Prüfung auf eine aktuell gültige Zahlungsoption
|
||||
* (aktives Abo in laufender Periode oder laufender Bestandsschutz).
|
||||
* Single Source of Truth für API-Gate und Buchungs-/Kontingent-Checks.
|
||||
*/
|
||||
public function hasActivePaymentOption(): bool
|
||||
{
|
||||
return $this->userPaymentOptions()->activeNow()->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -253,12 +257,7 @@ class User extends Authenticatable implements MustVerifyEmail
|
|||
return true;
|
||||
}
|
||||
|
||||
return $this->userPaymentOptions()
|
||||
->whereIn('status', [
|
||||
UserPaymentOptionStatus::Active->value,
|
||||
UserPaymentOptionStatus::Grandfathered->value,
|
||||
])
|
||||
->exists();
|
||||
return $this->hasActivePaymentOption();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -369,6 +368,15 @@ class User extends Authenticatable implements MustVerifyEmail
|
|||
return $this->hasMany(LegacyInvoice::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Neueste Legacy-Rechnung des Users (nach Rechnungsdatum, dann ID).
|
||||
* Zentralisiert die zuvor mehrfach reimplementierte „latest"-Abfrage.
|
||||
*/
|
||||
public function latestLegacyInvoice(): ?LegacyInvoice
|
||||
{
|
||||
return $this->legacyInvoices()->latestFirst()->first();
|
||||
}
|
||||
|
||||
public function filterPresets(): HasMany
|
||||
{
|
||||
return $this->hasMany(UserFilterPreset::class);
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
namespace App\Models;
|
||||
|
||||
use App\Enums\UserPaymentOptionStatus;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
|
@ -58,4 +59,33 @@ class UserPaymentOption extends Model
|
|||
{
|
||||
return $this->hasMany(UserPayment::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktuell gültige Zahlungsoption (period-aware): aktives Abo innerhalb der
|
||||
* laufenden Periode ODER laufender Bestandsschutz. Single Source of Truth
|
||||
* für „aktive Zahlungsoption" in API-Gate, Buchungs- und Kontingent-Checks.
|
||||
*/
|
||||
public function scopeActiveNow(Builder $query): Builder
|
||||
{
|
||||
return $query->where(function (Builder $query): void {
|
||||
$query
|
||||
->where(function (Builder $active): void {
|
||||
$active
|
||||
->where('status', UserPaymentOptionStatus::Active->value)
|
||||
->whereDate('current_period_start', '<=', now())
|
||||
->whereDate('current_period_end', '>=', now());
|
||||
})
|
||||
->orWhere(fn (Builder $grandfathered): Builder => $grandfathered->grandfatheredNow());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Laufender Bestandsschutz (Grandfathered mit gültigem grandfathered_until).
|
||||
*/
|
||||
public function scopeGrandfatheredNow(Builder $query): Builder
|
||||
{
|
||||
return $query
|
||||
->where('status', UserPaymentOptionStatus::Grandfathered->value)
|
||||
->whereDate('grandfathered_until', '>=', now());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,11 +31,12 @@ class ThemeServiceProvider extends ServiceProvider
|
|||
$host = Request::getHost();
|
||||
$themeOverride = Request::get('theme');
|
||||
|
||||
// Fallback für unbekannte Hosts: Portal-/Admin-Theme dieses Projekts.
|
||||
$defaults = [
|
||||
'name' => config('app.name'),
|
||||
'theme' => 'b2in',
|
||||
'view_prefix' => 'b2in',
|
||||
'assets_dir' => 'build/b2in',
|
||||
'theme' => 'portal',
|
||||
'view_prefix' => 'portal',
|
||||
'assets_dir' => 'build/portal',
|
||||
'url' => config('app.url'),
|
||||
'domain_name' => config('app.domain_name'),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
namespace App\Services\Api;
|
||||
|
||||
use App\Enums\UserPaymentOptionStatus;
|
||||
use App\Models\User;
|
||||
|
||||
class ApiAccessEligibilityService
|
||||
|
|
@ -18,40 +17,16 @@ class ApiAccessEligibilityService
|
|||
return 'Ihr Benutzerkonto ist nicht aktiv. API-Tokens können nur für aktive Benutzer erstellt werden.';
|
||||
}
|
||||
|
||||
if ($this->hasActivePaymentOption($user) || $this->hasPaidLegacyInvoice($user)) {
|
||||
if ($user->hasActivePaymentOption() || $this->hasPaidLegacyInvoice($user)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return 'API-Tokens werden erst freigeschaltet, wenn ein aktiver Zahlungsstatus oder freigegebener Bestandsschutz vorliegt.';
|
||||
}
|
||||
|
||||
private function hasActivePaymentOption(User $user): bool
|
||||
{
|
||||
return $user->userPaymentOptions()
|
||||
->where(function ($query): void {
|
||||
$query
|
||||
->where(function ($active): void {
|
||||
$active
|
||||
->where('status', UserPaymentOptionStatus::Active->value)
|
||||
->whereDate('current_period_start', '<=', now())
|
||||
->whereDate('current_period_end', '>=', now());
|
||||
})
|
||||
->orWhere(function ($grandfathered): void {
|
||||
$grandfathered
|
||||
->where('status', UserPaymentOptionStatus::Grandfathered->value)
|
||||
->whereDate('grandfathered_until', '>=', now());
|
||||
});
|
||||
})
|
||||
->exists();
|
||||
}
|
||||
|
||||
private function hasPaidLegacyInvoice(User $user): bool
|
||||
{
|
||||
$latestInvoice = $user->legacyInvoices()
|
||||
->latest('invoice_date')
|
||||
->latest('id')
|
||||
->first(['id', 'status']);
|
||||
|
||||
return $latestInvoice?->status === 'paid';
|
||||
// Bewusstes Verhalten dieses Gates: ohne Legacy-Rechnung kein Zugang.
|
||||
return $user->latestLegacyInvoice()?->status === 'paid';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -73,8 +73,7 @@ class LegacyApiCustomerReporter
|
|||
'paid_at',
|
||||
'total_cents',
|
||||
])
|
||||
->latest('invoice_date')
|
||||
->latest('id'),
|
||||
->latestFirst(),
|
||||
])
|
||||
->where(function ($query): void {
|
||||
$query->where('registration_type', RegistrationType::ApiUser->value)
|
||||
|
|
|
|||
|
|
@ -39,22 +39,21 @@ class InvoiceNumberGenerator
|
|||
}
|
||||
|
||||
$number = DB::transaction(function () use ($circle): int {
|
||||
// Sequenz-Zeile race-sicher sicherstellen: insertOrIgnore nutzt den
|
||||
// Unique-Index auf `circle`, sodass parallele Erstaufrufe nicht in
|
||||
// doppelte Zeilen (und damit doppelte Nummern) laufen.
|
||||
DB::table('invoice_number_sequences')->insertOrIgnore([
|
||||
'circle' => $circle,
|
||||
'next_number' => 1,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$sequence = DB::table('invoice_number_sequences')
|
||||
->where('circle', $circle)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if (! $sequence) {
|
||||
DB::table('invoice_number_sequences')->insert([
|
||||
'circle' => $circle,
|
||||
'next_number' => 2,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
DB::table('invoice_number_sequences')
|
||||
->where('id', $sequence->id)
|
||||
->update(['next_number' => $sequence->next_number + 1, 'updated_at' => now()]);
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
namespace App\Services\Customer;
|
||||
|
||||
use App\Enums\Tier;
|
||||
use App\Enums\UserPaymentOptionStatus;
|
||||
use App\Models\Plan;
|
||||
use App\Models\User;
|
||||
|
||||
|
|
@ -45,10 +44,7 @@ class CustomerStatusSummary
|
|||
}
|
||||
|
||||
$hasGrandfathered = $user->userPaymentOptions()
|
||||
->whereIn('status', [
|
||||
UserPaymentOptionStatus::Active->value,
|
||||
UserPaymentOptionStatus::Grandfathered->value,
|
||||
])
|
||||
->grandfatheredNow()
|
||||
->exists();
|
||||
|
||||
return new self(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue