security: Sicherheitsaudit 07.10.2026 (S-01–S-06) behoben, Passkeys und Zweitfaktor-Pflicht für Admins
Audit: dev/sicherheit/SICHERHEITSAUDIT-2026-10-07.md, Plan und Entscheidungen: dev/sicherheit/ENTWICKLUNGSPLAN-SICHERHEIT-2026-10-07.md. - S-01 Legacy-Rechnungen fremder Kunden nur mit billing:manage (auch Kundenroute). - S-02 Kontaktzuordnung auf der Firmenanzeige verlangt companies:write, $id gesperrt. - S-03 CompanyMembershipObserver: automatische Firmenrechte werden bei jeder Adress-/Zuordnungsänderung sofort neu bewertet; memberships:resync-auto. - S-04 Passwortwechsel beendet alle anderen Sessions (PasswordChanger), Reset alle Sessions und offene Login-Links; Fortify PUT /user/password abgeschaltet. API-Tokens bleiben bestehen. - S-05 Google: email_verified Pflicht, feste Bindung über die Provider-ID; Erstverknüpfung bestehender Konten außerhalb gmail/hd nur per Bestätigungslink in derselben Browser-Session. - S-06 Frischer Identitätsnachweis (Passwort, App-Code, E-Mail-Code oder Passkey) vor E-Mail-Wechsel, erster 2FA-Einrichtung und Token-Erzeugung; Hinweis-Mails an die (bisherige) Kontoadresse. - Entscheidung 5: Passkeys (Fortify, @laravel/passkeys) für alle; Super-Admins und Admins bestätigen die Session für den Admin-Bereich per Passkey oder App-Code (EnsureStrongAdminAuthentication, Notfallschalter AUTH_REQUIRE_STRONG_ADMIN); Reset des zweiten Faktors durch Super-Admins. Neue Migration: create_passkeys_table. Suite 2.403 grün (1 übersprungen), Larastan ohne Meldung. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
d1d2ab3de7
commit
1c07fcb84b
66 changed files with 4035 additions and 144 deletions
|
|
@ -3,7 +3,7 @@
|
|||
namespace App\Actions\Fortify;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use App\Services\Auth\PasswordChanger;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Laravel\Fortify\Contracts\UpdatesUserPasswords;
|
||||
|
||||
|
|
@ -12,7 +12,10 @@ class UpdateUserPassword implements UpdatesUserPasswords
|
|||
use PasswordValidationRules;
|
||||
|
||||
/**
|
||||
* Validate and update the user's password.
|
||||
* Validate and update the user's password. Die Fortify-Route dazu ist
|
||||
* abgeschaltet (Sicherheitsaudit 07.10.2026, Entscheidung 4); die Action
|
||||
* nutzt trotzdem den gemeinsamen PasswordChanger, falls sie wieder
|
||||
* aktiviert wird.
|
||||
*
|
||||
* @param array<string, string> $input
|
||||
*/
|
||||
|
|
@ -25,8 +28,6 @@ class UpdateUserPassword implements UpdatesUserPasswords
|
|||
'current_password.current_password' => __('The provided password does not match your current password.'),
|
||||
])->validateWithBag('updatePassword');
|
||||
|
||||
$user->forceFill([
|
||||
'password' => Hash::make($input['password']),
|
||||
])->save();
|
||||
app(PasswordChanger::class)->change($user, $input['password'], request()->hasSession() ? request()->session() : null);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
68
app/Console/Commands/ResyncAutomaticMemberships.php
Normal file
68
app/Console/Commands/ResyncAutomaticMemberships.php
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\ContactAccessService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Bewertet automatisch vergebene Firmenrechte (`granted_via` = auto bzw.
|
||||
* auto-upgrade) aller betroffenen User neu (Sicherheitsaudit 07.10.2026,
|
||||
* S-03). Der CompanyMembershipObserver erledigt das bei jeder Änderung über
|
||||
* Eloquent; dieses Kommando bereinigt den Altbestand und repariert nach
|
||||
* Massen-Updates per Query-Builder.
|
||||
*
|
||||
* Manuelle (`manual`) und API-Mitgliedschaften (`api`) bleiben unberührt.
|
||||
*/
|
||||
class ResyncAutomaticMemberships extends Command
|
||||
{
|
||||
protected $signature = 'memberships:resync-auto
|
||||
{--user= : Nur diesen User (ID) abgleichen}';
|
||||
|
||||
protected $description = 'Gleicht automatisch vergebene Firmenrechte mit den aktuellen Kontakt- und Firmenadressen ab.';
|
||||
|
||||
public function handle(ContactAccessService $contactAccess): int
|
||||
{
|
||||
$before = $this->countAutomaticGrants();
|
||||
|
||||
$userIds = DB::table('company_user')
|
||||
->whereIn('granted_via', ['auto', 'auto-upgrade'])
|
||||
->when($this->option('user'), fn ($query, $userId) => $query->where('user_id', (int) $userId))
|
||||
->distinct()
|
||||
->pluck('user_id');
|
||||
|
||||
if ($userIds->isEmpty()) {
|
||||
$this->info('Keine automatisch vergebenen Firmenrechte gefunden — nichts zu tun.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$bar = $this->output->createProgressBar($userIds->count());
|
||||
|
||||
foreach ($userIds->chunk(200) as $chunk) {
|
||||
User::query()->whereIn('id', $chunk->all())->get()->each(function (User $user) use ($contactAccess, $bar): void {
|
||||
$contactAccess->syncMembershipsForEmail($user);
|
||||
$bar->advance();
|
||||
});
|
||||
}
|
||||
|
||||
$bar->finish();
|
||||
$this->newLine();
|
||||
|
||||
$after = $this->countAutomaticGrants();
|
||||
|
||||
$this->info("Abgeglichen: {$userIds->count()} User. Automatische Rechte vorher {$before}, nachher {$after}.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function countAutomaticGrants(): int
|
||||
{
|
||||
return DB::table('company_user')
|
||||
->where('role', 'responsible')
|
||||
->whereIn('granted_via', ['auto', 'auto-upgrade'])
|
||||
->count();
|
||||
}
|
||||
}
|
||||
51
app/Enums/SecurityChange.php
Normal file
51
app/Enums/SecurityChange.php
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
/**
|
||||
* Sicherheitsrelevante Kontoänderung, über die der Kontoinhaber an seine
|
||||
* bisherige Adresse informiert wird (Sicherheitsaudit 07.10.2026, S-06).
|
||||
*/
|
||||
enum SecurityChange: string
|
||||
{
|
||||
case PasswordChanged = 'password_changed';
|
||||
|
||||
case PasswordReset = 'password_reset';
|
||||
|
||||
case EmailChanged = 'email_changed';
|
||||
|
||||
case TwoFactorEnabled = 'two_factor_enabled';
|
||||
|
||||
case TwoFactorDisabled = 'two_factor_disabled';
|
||||
|
||||
case GoogleLinked = 'google_linked';
|
||||
|
||||
case PasskeyAdded = 'passkey_added';
|
||||
|
||||
case PasskeyRemoved = 'passkey_removed';
|
||||
|
||||
case SecondFactorsReset = 'second_factors_reset';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::PasswordChanged => 'Ihr Passwort wurde geändert.',
|
||||
self::PasswordReset => 'Ihr Passwort wurde über „Passwort vergessen“ neu gesetzt.',
|
||||
self::EmailChanged => 'Die Login-Adresse Ihres Kontos wurde geändert.',
|
||||
self::TwoFactorEnabled => 'Die Zwei-Faktor-Authentifizierung wurde aktiviert.',
|
||||
self::TwoFactorDisabled => 'Die Zwei-Faktor-Authentifizierung wurde deaktiviert.',
|
||||
self::GoogleLinked => 'Ein Google-Konto wurde mit Ihrem Konto verknüpft.',
|
||||
self::PasskeyAdded => 'Ein neuer Passkey wurde zu Ihrem Konto hinzugefügt.',
|
||||
self::PasskeyRemoved => 'Ein Passkey wurde aus Ihrem Konto entfernt.',
|
||||
self::SecondFactorsReset => 'Ein Administrator hat Ihre Passkeys und Ihre Zwei-Faktor-Authentifizierung zurückgesetzt.',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ob andere angemeldete Geräte dabei abgemeldet wurden.
|
||||
*/
|
||||
public function endsOtherSessions(): bool
|
||||
{
|
||||
return in_array($this, [self::PasswordChanged, self::PasswordReset], true);
|
||||
}
|
||||
}
|
||||
|
|
@ -2,9 +2,11 @@
|
|||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Enums\SecurityChange;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\ContactAccessService;
|
||||
use App\Services\Auth\SecurityNotifier;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
|
|
@ -15,7 +17,7 @@ class ConfirmEmailChangeController extends Controller
|
|||
* signierten Link aus der Mail an die NEUE Adresse geklickt hat.
|
||||
* Bis dahin bleibt die alte Adresse aktiv (kein Aussperren bei Tippfehlern).
|
||||
*/
|
||||
public function __invoke(Request $request, User $user, string $hash, ContactAccessService $contactAccess): RedirectResponse
|
||||
public function __invoke(Request $request, User $user, string $hash, ContactAccessService $contactAccess, SecurityNotifier $notifier): RedirectResponse
|
||||
{
|
||||
abort_unless($request->user()->is($user), 403);
|
||||
|
||||
|
|
@ -34,6 +36,8 @@ class ConfirmEmailChangeController extends Controller
|
|||
->with('security-error', __('Die neue E-Mail-Adresse wird inzwischen von einem anderen Konto verwendet. Die Änderung wurde verworfen.'));
|
||||
}
|
||||
|
||||
$previousEmail = (string) $user->email;
|
||||
|
||||
$user->forceFill([
|
||||
'email' => $user->pending_email,
|
||||
'email_verified_at' => now(),
|
||||
|
|
@ -45,6 +49,11 @@ class ConfirmEmailChangeController extends Controller
|
|||
// vergeben – nicht erst beim nächsten Login.
|
||||
$contactAccess->syncMembershipsForEmail($user);
|
||||
|
||||
// Die bisherige Adresse erfährt von der Änderung (Sicherheitsaudit
|
||||
// 07.10.2026, S-06) — sonst bliebe eine Übernahme per fremder Session
|
||||
// für den Inhaber unsichtbar.
|
||||
$notifier->notify($user, SecurityChange::EmailChanged, recipient: $previousEmail, newEmail: (string) $user->email);
|
||||
|
||||
return redirect()->route('me.security')
|
||||
->with('security-status', __('Ihre E-Mail-Adresse wurde erfolgreich geändert.'));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,17 +3,24 @@
|
|||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\LoginCompletion;
|
||||
use App\Services\Auth\SocialAccountLinker;
|
||||
use App\Services\Auth\SocialAuthService;
|
||||
use App\Services\Auth\SocialLoginResult;
|
||||
use App\Support\LoginRedirect;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Laravel\Socialite\AbstractUser;
|
||||
use Laravel\Socialite\Facades\Socialite;
|
||||
|
||||
/**
|
||||
* Google-Login (WS-6). Erst-Login legt – de-dupliziert über die E-Mail – einen
|
||||
* aktiven, verifizierten customer-Account an; bestehende Accounts werden nur
|
||||
* verknüpft. Deaktivierte Accounts werden nicht reaktiviert.
|
||||
* aktiven, verifizierten customer-Account an; bestehende Accounts werden
|
||||
* verknüpft, bei Adressen außerhalb von Googles Zuständigkeit erst nach
|
||||
* Bestätigung per E-Mail (Sicherheitsaudit 07.10.2026, S-05). Deaktivierte
|
||||
* Accounts werden nicht reaktiviert.
|
||||
*/
|
||||
class GoogleController extends Controller
|
||||
{
|
||||
|
|
@ -22,29 +29,72 @@ class GoogleController extends Controller
|
|||
return Socialite::driver('google')->redirect();
|
||||
}
|
||||
|
||||
public function callback(Request $request, SocialAuthService $social, LoginCompletion $completion): RedirectResponse
|
||||
public function callback(Request $request, SocialAuthService $social, SocialAccountLinker $linker, LoginCompletion $completion): RedirectResponse
|
||||
{
|
||||
try {
|
||||
$googleUser = Socialite::driver('google')->user();
|
||||
} catch (\Throwable) {
|
||||
return redirect()->route('login')->withErrors([
|
||||
'email' => __('Die Anmeldung mit Google ist fehlgeschlagen. Bitte versuchen Sie es erneut.'),
|
||||
]);
|
||||
return $this->failed(__('Die Anmeldung mit Google ist fehlgeschlagen. Bitte versuchen Sie es erneut.'));
|
||||
}
|
||||
|
||||
$user = $social->resolveUser(
|
||||
$raw = $googleUser instanceof AbstractUser ? $googleUser->getRaw() : [];
|
||||
$providerId = (string) $googleUser->getId();
|
||||
|
||||
$result = $social->resolve(
|
||||
'google',
|
||||
(string) $googleUser->getId(),
|
||||
$providerId,
|
||||
$googleUser->getEmail(),
|
||||
$googleUser->getName(),
|
||||
emailVerified: filter_var($raw['email_verified'] ?? false, FILTER_VALIDATE_BOOLEAN),
|
||||
hostedDomain: isset($raw['hd']) ? (string) $raw['hd'] : null,
|
||||
);
|
||||
|
||||
if (! $user || ! $user->is_active) {
|
||||
return redirect()->route('login')->withErrors([
|
||||
'email' => __('Ihr Konto ist nicht aktiv. Bitte wenden Sie sich an den Support.'),
|
||||
]);
|
||||
if ($result->outcome === SocialLoginResult::UNVERIFIED_EMAIL) {
|
||||
return $this->failed(__('Google hat keine bestätigte E-Mail-Adresse übermittelt. Bitte melden Sie sich per E-Mail-Link an.'));
|
||||
}
|
||||
|
||||
if ($result->outcome === SocialLoginResult::PROVIDER_MISMATCH) {
|
||||
Log::warning('google-login.provider-mismatch', ['user_id' => $result->user?->getKey()]);
|
||||
|
||||
return $this->failed(__('Ihr Konto ist bereits mit einem anderen Google-Konto verknüpft. Bitte melden Sie sich per E-Mail-Link an.'));
|
||||
}
|
||||
|
||||
$user = $result->user;
|
||||
|
||||
if (! $user || ! $user->is_active) {
|
||||
return $this->failed(__('Ihr Konto ist nicht aktiv. Bitte wenden Sie sich an den Support.'));
|
||||
}
|
||||
|
||||
if ($result->outcome === SocialLoginResult::CONFIRM_LINK) {
|
||||
if (! $linker->requestConfirmation($user, 'google', $providerId)) {
|
||||
return $this->failed(__('Zu viele Anfragen. Bitte warten Sie einige Minuten und versuchen Sie es erneut.'));
|
||||
}
|
||||
|
||||
return redirect()->route('login')->with('status', __('Für Ihr bestehendes Konto ist eine einmalige Bestätigung nötig: Wir haben Ihnen einen Link an Ihre E-Mail-Adresse geschickt. Bitte öffnen Sie ihn in diesem Browser.'));
|
||||
}
|
||||
|
||||
return $this->completeLogin($request, $user, $completion);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bestätigungslink aus der Mail (SocialAccountLinker): verknüpft und
|
||||
* meldet an — nur in der Browser-Session, die den Google-Login begonnen hat.
|
||||
*/
|
||||
public function confirmLink(Request $request, User $user, SocialAccountLinker $linker, LoginCompletion $completion): RedirectResponse
|
||||
{
|
||||
if (! $linker->confirm($user, (string) $request->query('provider_id', ''))) {
|
||||
return $this->failed(__('Der Bestätigungslink ist ungültig oder abgelaufen. Bitte öffnen Sie ihn im selben Browser, in dem Sie die Anmeldung mit Google gestartet haben, oder starten Sie die Anmeldung erneut.'));
|
||||
}
|
||||
|
||||
if (! $user->is_active) {
|
||||
return $this->failed(__('Ihr Konto ist nicht aktiv. Bitte wenden Sie sich an den Support.'));
|
||||
}
|
||||
|
||||
return $this->completeLogin($request, $user, $completion);
|
||||
}
|
||||
|
||||
private function completeLogin(Request $request, User $user, LoginCompletion $completion): RedirectResponse
|
||||
{
|
||||
// 2FA aktiv: wie Passwort- und Magic-Link-Login nicht direkt einloggen,
|
||||
// sondern in Fortifys Challenge übergeben (Audit F02). Google belegt
|
||||
// den Besitz des Postfachs, nicht den zweiten Faktor.
|
||||
|
|
@ -65,4 +115,9 @@ class GoogleController extends Controller
|
|||
LoginRedirect::homeFor($user),
|
||||
));
|
||||
}
|
||||
|
||||
private function failed(string $message): RedirectResponse
|
||||
{
|
||||
return redirect()->route('login')->withErrors(['email' => $message]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,9 @@ class BlockUnusedFortifyRoutes
|
|||
'two-factor.secret-key',
|
||||
'two-factor.recovery-codes',
|
||||
'two-factor.regenerate-recovery-codes',
|
||||
// Passkeys löscht die Sicherheitsseite mit Identitätsnachweis
|
||||
// (Entscheidung 5, 07.10.2026).
|
||||
'passkey.destroy',
|
||||
];
|
||||
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
|
|
|
|||
49
app/Http/Middleware/EnsurePasskeyManagementIsConfirmed.php
Normal file
49
app/Http/Middleware/EnsurePasskeyManagementIsConfirmed.php
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Actions\Admin\UserImpersonation;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\SecurityConfirmation;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Passkey hinzufügen nur mit frischem Identitätsnachweis (Entscheidung 5,
|
||||
* Sicherheitsaudit 07.10.2026, S-06): Ein Passkey ist dauerhafter Zugang —
|
||||
* eine fremde Session allein darf keinen hinterlegen. Den Nachweis erbringt
|
||||
* die Sicherheitsseite (ConfirmsIdentity), bevor sie die Registrierung im
|
||||
* Browser startet. Während einer Impersonation nie.
|
||||
*
|
||||
* Hängt über `fortify.middleware` an allen Fortify-Routen und greift nur bei
|
||||
* den Registrierungsrouten. Gelöscht wird über die Sicherheitsseite; die
|
||||
* Fortify-Löschroute sperrt BlockUnusedFortifyRoutes.
|
||||
*/
|
||||
class EnsurePasskeyManagementIsConfirmed
|
||||
{
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
public const GUARDED_ROUTES = [
|
||||
'passkey.registration-options',
|
||||
'passkey.store',
|
||||
];
|
||||
|
||||
public function __construct(private readonly SecurityConfirmation $confirmation) {}
|
||||
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (! in_array($request->route()?->getName(), self::GUARDED_ROUTES, true)) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
if (! $user instanceof User || app(UserImpersonation::class)->isActive() || ! $this->confirmation->isFresh($user)) {
|
||||
abort(403, __('Bitte bestätigen Sie zuerst Ihre Identität.'));
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
38
app/Http/Middleware/EnsureStrongAdminAuthentication.php
Normal file
38
app/Http/Middleware/EnsureStrongAdminAuthentication.php
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\StrongAuthentication;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Admin-Bereich nur mit starker Anmeldung (Entscheidung 5, 07.10.2026):
|
||||
* Super-Admins und Admins bestätigen ihre Session einmalig per Passkey oder
|
||||
* Authenticator-Code. Seitenaufrufe führen zur Bestätigungsseite und danach
|
||||
* zurück; Livewire- und JSON-Folgeanfragen werden abgewiesen. Editoren und
|
||||
* Kunden sind nicht betroffen. Läuft auch als persistente Livewire-Middleware.
|
||||
*/
|
||||
class EnsureStrongAdminAuthentication
|
||||
{
|
||||
public function __construct(private readonly StrongAuthentication $strong) {}
|
||||
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
if (! $user instanceof User || ! $this->strong->isRequiredFor($user) || $this->strong->isSatisfiedBy($user)) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
if (! $request->isMethod('GET') || $request->expectsJson()) {
|
||||
abort(403, __('Bitte bestätigen Sie Ihre Anmeldung mit Passkey oder Authenticator-Code.'));
|
||||
}
|
||||
|
||||
$request->session()->put(StrongAuthentication::INTENDED_KEY, $request->fullUrl());
|
||||
|
||||
return redirect()->route('strong-auth.confirm');
|
||||
}
|
||||
}
|
||||
38
app/Http/Responses/PasskeyLoginResponse.php
Normal file
38
app/Http/Responses/PasskeyLoginResponse.php
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Responses;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\LoginCompletion;
|
||||
use App\Support\LoginRedirect;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Laravel\Passkeys\Contracts\PasskeyLoginResponse as PasskeyLoginResponseContract;
|
||||
|
||||
/**
|
||||
* Abschluss eines Passkey-Logins (Entscheidung 5, 07.10.2026): wie
|
||||
* RoleAwareLoginResponse den Login protokollieren und nur auf erlaubte Ziele
|
||||
* weiterleiten. Aktiv- und Verifizierungsstatus prüft bereits
|
||||
* Passkeys::authorizeLoginUsing() vor der Anmeldung (FortifyServiceProvider).
|
||||
* Der Browser-Client erwartet JSON mit `redirect`.
|
||||
*/
|
||||
class PasskeyLoginResponse implements PasskeyLoginResponseContract
|
||||
{
|
||||
public function toResponse($request): JsonResponse|RedirectResponse
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $request->user();
|
||||
|
||||
app(LoginCompletion::class)->recordLogin($user, $request);
|
||||
|
||||
$target = LoginRedirect::safeTarget(
|
||||
$user,
|
||||
$request->session()->pull('url.intended'),
|
||||
LoginRedirect::homeFor($user),
|
||||
);
|
||||
|
||||
return $request->wantsJson()
|
||||
? new JsonResponse(['redirect' => $target])
|
||||
: redirect($target);
|
||||
}
|
||||
}
|
||||
42
app/Listeners/RevokeAccessAfterPasswordReset.php
Normal file
42
app/Listeners/RevokeAccessAfterPasswordReset.php
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
<?php
|
||||
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Enums\SecurityChange;
|
||||
use App\Models\MagicLink;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\SecurityNotifier;
|
||||
use App\Services\Auth\SessionRevocation;
|
||||
use Illuminate\Auth\Events\PasswordReset;
|
||||
|
||||
/**
|
||||
* Passwort-Reset als Kontowiederherstellung (Sicherheitsaudit 07.10.2026,
|
||||
* S-04): Alle offenen Browser-Sessions enden, noch nicht eingelöste
|
||||
* Anmeldelinks verfallen. Den Remember-Token rotiert die Reset-Komponente
|
||||
* selbst. API-Tokens bleiben bestehen (Entscheidung 1) — der Hinweis an die
|
||||
* Kontoadresse weist darauf hin.
|
||||
*/
|
||||
class RevokeAccessAfterPasswordReset
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SessionRevocation $sessions,
|
||||
private readonly SecurityNotifier $notifier,
|
||||
) {}
|
||||
|
||||
public function handle(PasswordReset $event): void
|
||||
{
|
||||
if (! $event->user instanceof User) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->sessions->revokeAllFor($event->user);
|
||||
|
||||
MagicLink::query()
|
||||
->where('user_id', $event->user->getKey())
|
||||
->whereIn('purpose', MagicLink::LOGIN_PURPOSES)
|
||||
->whereNull('consumed_at')
|
||||
->update(['consumed_at' => now()]);
|
||||
|
||||
$this->notifier->notify($event->user, SecurityChange::PasswordReset);
|
||||
}
|
||||
}
|
||||
71
app/Listeners/TrackSecondFactorEvents.php
Normal file
71
app/Listeners/TrackSecondFactorEvents.php
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
<?php
|
||||
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Enums\SecurityChange;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\SecurityConfirmation;
|
||||
use App\Services\Auth\SecurityNotifier;
|
||||
use App\Services\Auth\StrongAuthentication;
|
||||
use Laravel\Fortify\Events\ValidTwoFactorAuthenticationCodeProvided;
|
||||
use Laravel\Passkeys\Events\PasskeyDeleted;
|
||||
use Laravel\Passkeys\Events\PasskeyRegistered;
|
||||
use Laravel\Passkeys\Events\PasskeyVerified;
|
||||
|
||||
/**
|
||||
* Verbindet Passkeys und Authenticator-Codes mit den eigenen Schutzschichten
|
||||
* (Entscheidung 5, 07.10.2026):
|
||||
* - Ein geprüfter Passkey (Login oder Bestätigung) oder ein gültiger Code
|
||||
* in der 2FA-Challenge macht die Session „stark“ (Admin-Bereich).
|
||||
* - Ein geprüfter Passkey gilt zugleich als frischer Identitätsnachweis
|
||||
* für Sicherheitsänderungen (S-06).
|
||||
* - Hinzugefügte und entfernte Passkeys werden an die Kontoadresse gemeldet.
|
||||
*/
|
||||
class TrackSecondFactorEvents
|
||||
{
|
||||
public function __construct(
|
||||
private readonly StrongAuthentication $strong,
|
||||
private readonly SecurityConfirmation $confirmation,
|
||||
private readonly SecurityNotifier $notifier,
|
||||
) {}
|
||||
|
||||
public function handlePasskeyVerified(PasskeyVerified $event): void
|
||||
{
|
||||
if (! $event->user instanceof User) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->strong->mark($event->user, 'passkey');
|
||||
$this->confirmation->markConfirmed($event->user);
|
||||
|
||||
// Login per Passkey: Anmeldeweg für das Aktivitätsprotokoll.
|
||||
if (! auth()->guard('web')->check()) {
|
||||
request()->attributes->set(RecordAuthActivity::AUTH_METHOD_ATTRIBUTE, 'passkey');
|
||||
}
|
||||
}
|
||||
|
||||
public function handleValidTwoFactorCode(ValidTwoFactorAuthenticationCodeProvided $event): void
|
||||
{
|
||||
$this->strong->mark($event->user, 'totp');
|
||||
}
|
||||
|
||||
public function handlePasskeyRegistered(PasskeyRegistered $event): void
|
||||
{
|
||||
if (! $event->user instanceof User) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Die Registrierung verlangt Benutzerverifizierung am Gerät — die
|
||||
// Session ist damit ebenso stark bestätigt wie durch einen Login.
|
||||
$this->strong->mark($event->user, 'passkey');
|
||||
|
||||
$this->notifier->notify($event->user, SecurityChange::PasskeyAdded);
|
||||
}
|
||||
|
||||
public function handlePasskeyDeleted(PasskeyDeleted $event): void
|
||||
{
|
||||
if ($event->user instanceof User) {
|
||||
$this->notifier->notify($event->user, SecurityChange::PasskeyRemoved);
|
||||
}
|
||||
}
|
||||
}
|
||||
142
app/Livewire/Concerns/ConfirmsIdentity.php
Normal file
142
app/Livewire/Concerns/ConfirmsIdentity.php
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Concerns;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\SecurityConfirmation;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Fortify\TwoFactorAuthenticationProvider;
|
||||
|
||||
/**
|
||||
* Erneuter Nachweis vor sicherheitskritischen Aktionen (Audit F03, E3;
|
||||
* Sicherheitsaudit 07.10.2026, S-06). Je nach Kontoart:
|
||||
* - 2FA aktiv → aktueller App-Code oder Wiederherstellungs-Code,
|
||||
* - Passwort gesetzt → Passwort,
|
||||
* - sonst → Einmal-Code an die aktuelle Kontoadresse.
|
||||
*
|
||||
* Nach erfolgreichem Nachweis gilt die Session eine Weile als bestätigt
|
||||
* (SecurityConfirmation). Für das Abschalten der 2FA und neue
|
||||
* Wiederherstellungs-Codes verlangt assertSecondFactor() dagegen immer einen
|
||||
* aktuellen zweiten Faktor. Das Formular dazu: `partials.security-proof`.
|
||||
*/
|
||||
trait ConfirmsIdentity
|
||||
{
|
||||
public string $securityCode = '';
|
||||
|
||||
public string $securityPassword = '';
|
||||
|
||||
public string $securityEmailCode = '';
|
||||
|
||||
public bool $securityEmailCodeSent = false;
|
||||
|
||||
public function requestSecurityEmailCode(): void
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = auth()->user();
|
||||
|
||||
if (! app(SecurityConfirmation::class)->sendEmailCode($user)) {
|
||||
throw ValidationException::withMessages([
|
||||
'securityEmailCode' => __('Zu viele Codes angefordert. Bitte warten Sie einige Minuten.'),
|
||||
]);
|
||||
}
|
||||
|
||||
$this->securityEmailCodeSent = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Art des Nachweises für die Anzeige: fresh, two_factor, password oder email_code.
|
||||
*/
|
||||
public function securityProofKind(User $user): string
|
||||
{
|
||||
return match (true) {
|
||||
app(SecurityConfirmation::class)->isFresh($user) => 'fresh',
|
||||
$user->hasEnabledTwoFactorAuthentication() => 'two_factor',
|
||||
(string) $user->password !== '' => 'password',
|
||||
default => 'email_code',
|
||||
};
|
||||
}
|
||||
|
||||
protected function assertSecurityProof(User $user): void
|
||||
{
|
||||
$confirmation = app(SecurityConfirmation::class);
|
||||
|
||||
if ($confirmation->isFresh($user)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($user->hasEnabledTwoFactorAuthentication()) {
|
||||
$this->assertSecondFactor($user);
|
||||
} elseif ((string) $user->password !== '') {
|
||||
try {
|
||||
$this->validate(['securityPassword' => ['required', 'string', 'current_password']]);
|
||||
} finally {
|
||||
$this->reset('securityPassword');
|
||||
}
|
||||
} else {
|
||||
$this->assertEmailCode($user);
|
||||
}
|
||||
|
||||
$confirmation->markConfirmed($user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft `securityCode` als TOTP (Fortify-Provider, replay-geschützt) oder
|
||||
* als Wiederherstellungs-Code (wird dabei verbraucht). Fünf Fehlversuche
|
||||
* pro Minute, damit eine Session die sechs Stellen nicht durchprobiert.
|
||||
*/
|
||||
protected function assertSecondFactor(User $user): void
|
||||
{
|
||||
$this->validate(['securityCode' => ['required', 'string']]);
|
||||
|
||||
$code = trim($this->securityCode);
|
||||
$this->reset('securityCode');
|
||||
|
||||
$throttleKey = 'security-proof:'.$user->getKey();
|
||||
|
||||
if (RateLimiter::tooManyAttempts($throttleKey, 5)) {
|
||||
throw ValidationException::withMessages([
|
||||
'securityCode' => __('Zu viele Fehlversuche. Bitte warten Sie eine Minute.'),
|
||||
]);
|
||||
}
|
||||
|
||||
$secret = decrypt($user->two_factor_secret);
|
||||
|
||||
if (app(TwoFactorAuthenticationProvider::class)->verify($secret, $code)) {
|
||||
RateLimiter::clear($throttleKey);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (in_array($code, $user->recoveryCodes() ?? [], true)) {
|
||||
$user->replaceRecoveryCode($code);
|
||||
RateLimiter::clear($throttleKey);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
RateLimiter::hit($throttleKey, 60);
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'securityCode' => __('Der Code ist ungültig. Geben Sie einen aktuellen Code aus Ihrer Authenticator-App oder einen Wiederherstellungs-Code ein.'),
|
||||
]);
|
||||
}
|
||||
|
||||
private function assertEmailCode(User $user): void
|
||||
{
|
||||
$this->validate(['securityEmailCode' => ['required', 'string']], [
|
||||
'securityEmailCode.required' => __('Bitte fordern Sie einen Bestätigungscode per E-Mail an und geben Sie ihn ein.'),
|
||||
]);
|
||||
|
||||
$code = $this->securityEmailCode;
|
||||
$this->reset('securityEmailCode');
|
||||
|
||||
if (! app(SecurityConfirmation::class)->verifyEmailCode($user, $code)) {
|
||||
throw ValidationException::withMessages([
|
||||
'securityEmailCode' => __('Der Code ist ungültig oder abgelaufen. Bitte fordern Sie einen neuen Code an.'),
|
||||
]);
|
||||
}
|
||||
|
||||
$this->securityEmailCodeSent = false;
|
||||
}
|
||||
}
|
||||
54
app/Mail/SecurityChangeNotice.php
Normal file
54
app/Mail/SecurityChangeNotice.php
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Enums\SecurityChange;
|
||||
use App\Models\User;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Mail\Mailables\Headers;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* Hinweis an die (bisherige) Kontoadresse nach einer sicherheitsrelevanten
|
||||
* Änderung (Sicherheitsaudit 07.10.2026, S-06). Enthält bewusst keinen Link
|
||||
* mit Anmeldewirkung und keine Codes — nur Art und Zeitpunkt der Änderung.
|
||||
*/
|
||||
class SecurityChangeNotice extends Mailable implements ShouldQueue
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public User $user,
|
||||
public SecurityChange $change,
|
||||
public string $occurredAt,
|
||||
public ?string $newEmail = null,
|
||||
) {}
|
||||
|
||||
public function headers(): Headers
|
||||
{
|
||||
return new Headers(text: [(string) config('bounces.reference_header', 'X-Pk-Ref') => 'user:'.$this->user->id]);
|
||||
}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
subject: 'Sicherheitshinweis zu Ihrem pressekonto'
|
||||
);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
view: 'emails.auth.security-change-notice'
|
||||
);
|
||||
}
|
||||
|
||||
public function attachments(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
51
app/Mail/SecurityConfirmationCode.php
Normal file
51
app/Mail/SecurityConfirmationCode.php
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Mail\Mailables\Headers;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* Einmal-Code für den Identitätsnachweis von Konten ohne Passwort und ohne
|
||||
* 2FA (Sicherheitsaudit 07.10.2026, S-06).
|
||||
*/
|
||||
class SecurityConfirmationCode extends Mailable implements ShouldQueue
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public User $user,
|
||||
public string $code,
|
||||
public string $expiresAt,
|
||||
) {}
|
||||
|
||||
public function headers(): Headers
|
||||
{
|
||||
return new Headers(text: [(string) config('bounces.reference_header', 'X-Pk-Ref') => 'user:'.$this->user->id]);
|
||||
}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
subject: 'Ihr Bestätigungscode für pressekonto'
|
||||
);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
view: 'emails.auth.security-confirmation-code'
|
||||
);
|
||||
}
|
||||
|
||||
public function attachments(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
51
app/Mail/SocialAccountLinkConfirmation.php
Normal file
51
app/Mail/SocialAccountLinkConfirmation.php
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Mail\Mailables\Headers;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* Bestätigungslink für die Erstverknüpfung eines Google-Kontos mit einem
|
||||
* bestehenden Konto (Sicherheitsaudit 07.10.2026, S-05).
|
||||
*/
|
||||
class SocialAccountLinkConfirmation extends Mailable implements ShouldQueue
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public User $user,
|
||||
public string $confirmUrl,
|
||||
public string $expiresAt,
|
||||
) {}
|
||||
|
||||
public function headers(): Headers
|
||||
{
|
||||
return new Headers(text: [(string) config('bounces.reference_header', 'X-Pk-Ref') => 'user:'.$this->user->id]);
|
||||
}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
subject: 'Google-Anmeldung für Ihr pressekonto bestätigen'
|
||||
);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
view: 'emails.auth.social-account-link-confirmation'
|
||||
);
|
||||
}
|
||||
|
||||
public function attachments(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
|
@ -19,14 +19,16 @@ use Illuminate\Notifications\Notifiable;
|
|||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Cashier\Billable;
|
||||
use Laravel\Fortify\Contracts\PasskeyUser;
|
||||
use Laravel\Fortify\PasskeyAuthenticatable;
|
||||
use Laravel\Fortify\TwoFactorAuthenticatable;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
use Spatie\Permission\Traits\HasRoles;
|
||||
|
||||
class User extends Authenticatable implements MustVerifyEmail
|
||||
class User extends Authenticatable implements MustVerifyEmail, PasskeyUser
|
||||
{
|
||||
/** @use HasFactory<UserFactory> */
|
||||
use Billable, HasApiTokens, HasFactory, HasRoles, Notifiable, SoftDeletes, TwoFactorAuthenticatable;
|
||||
use Billable, HasApiTokens, HasFactory, HasRoles, Notifiable, PasskeyAuthenticatable, SoftDeletes, TwoFactorAuthenticatable;
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
|
|
|
|||
105
app/Observers/CompanyMembershipObserver.php
Normal file
105
app/Observers/CompanyMembershipObserver.php
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
<?php
|
||||
|
||||
namespace App\Observers;
|
||||
|
||||
use App\Models\Company;
|
||||
use App\Models\Contact;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\ContactAccessService;
|
||||
use App\Support\EmailQuery;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Bewertet automatisch vergebene Firmenrechte neu, sobald sich ihre
|
||||
* Grundlage ändert (Sicherheitsaudit 07.10.2026, S-03): Kontakt- oder
|
||||
* Firmenadresse geändert, Kontakt in eine andere Firma verschoben, gelöscht
|
||||
* oder wiederhergestellt, Login-Adresse eines Users geändert.
|
||||
*
|
||||
* Bisher geschah das nur beim Login — ein aktiver Nutzer behielt entzogene
|
||||
* Rechte so lange, wie seine Session lief. Am Model statt in den
|
||||
* Formularen: Kunden-Newsroom, Admin-CRM, API und Importer schreiben dieselben
|
||||
* Felder. Läuft synchron in derselben Transaktion; ein Rollback nimmt die
|
||||
* Rechteänderung mit zurück. Massen-Updates per Query-Builder lösen keine
|
||||
* Events aus — dafür `php artisan memberships:resync-auto`.
|
||||
*/
|
||||
class CompanyMembershipObserver
|
||||
{
|
||||
public function __construct(private readonly ContactAccessService $contactAccess) {}
|
||||
|
||||
public function updated(Model $model): void
|
||||
{
|
||||
if ($model instanceof User) {
|
||||
if ($model->wasChanged('email')) {
|
||||
$this->contactAccess->syncMembershipsForEmail($model);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$relevant = $model instanceof Contact ? ['email', 'company_id'] : ['email'];
|
||||
|
||||
if (! $model->wasChanged($relevant)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->resync(
|
||||
emails: [$model->getOriginal('email'), $model->getAttribute('email')],
|
||||
companyIds: $this->companyIdsOf($model, withOriginal: true),
|
||||
);
|
||||
}
|
||||
|
||||
public function deleted(Model $model): void
|
||||
{
|
||||
if ($model instanceof User) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->resync([$model->getAttribute('email')], $this->companyIdsOf($model));
|
||||
}
|
||||
|
||||
public function restored(Model $model): void
|
||||
{
|
||||
$this->deleted($model);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<int>
|
||||
*/
|
||||
private function companyIdsOf(Company|Contact|Model $model, bool $withOriginal = false): array
|
||||
{
|
||||
$ids = $model instanceof Contact
|
||||
? [$model->getAttribute('company_id'), $withOriginal ? $model->getOriginal('company_id') : null]
|
||||
: [$model->getKey()];
|
||||
|
||||
return array_values(array_unique(array_map('intval', array_filter($ids))));
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle User, deren automatische Rechte an den betroffenen Firmen hängen,
|
||||
* sowie alle User mit einer der betroffenen Adressen.
|
||||
*
|
||||
* @param list<string|null> $emails
|
||||
* @param list<int> $companyIds
|
||||
*/
|
||||
private function resync(array $emails, array $companyIds): void
|
||||
{
|
||||
$userIds = $companyIds === [] ? collect() : DB::table('company_user')
|
||||
->whereIn('company_id', $companyIds)
|
||||
->whereIn('granted_via', ['auto', 'auto-upgrade'])
|
||||
->pluck('user_id');
|
||||
|
||||
/** @var Collection<int, User> $users */
|
||||
$users = User::query()->whereIn('id', $userIds->all())->get();
|
||||
|
||||
foreach (array_unique(array_filter(array_map(
|
||||
fn (?string $email): string => mb_strtolower(trim((string) $email)),
|
||||
$emails,
|
||||
))) as $email) {
|
||||
$users = $users->merge(EmailQuery::whereEmail(User::query(), $email)->get());
|
||||
}
|
||||
|
||||
$users->unique('id')->each(fn (User $user) => $this->contactAccess->syncMembershipsForEmail($user));
|
||||
}
|
||||
}
|
||||
|
|
@ -2,9 +2,16 @@
|
|||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Authorization\Permission;
|
||||
use App\Models\LegacyInvoice;
|
||||
use App\Models\User;
|
||||
|
||||
/**
|
||||
* Alt-Rechnungen aus dem Legacy-Import. Kunden sehen ihre eigenen Belege,
|
||||
* fremde nur mit `billing:manage` — der Panel-Zugang (Editor) allein reicht
|
||||
* nicht (Sicherheitsaudit 07.10.2026, S-01). Gilt für die Kunden- und die
|
||||
* Admin-Downloadroute gleichermaßen.
|
||||
*/
|
||||
class LegacyInvoicePolicy
|
||||
{
|
||||
public function before(User $user): ?bool
|
||||
|
|
@ -19,7 +26,7 @@ class LegacyInvoicePolicy
|
|||
|
||||
public function view(User $user, LegacyInvoice $legacyInvoice): bool
|
||||
{
|
||||
if ($user->canAccessAdmin()) {
|
||||
if ($user->can(Permission::BillingManage)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ use App\Contracts\NewsletterSyncClient;
|
|||
use App\Exceptions\MailDeliveryPausedException;
|
||||
use App\Helpers\ThemeHelper;
|
||||
use App\Http\Middleware\BlockAdminDuringImpersonation;
|
||||
use App\Http\Middleware\EnsureStrongAdminAuthentication;
|
||||
use App\Http\Middleware\EnsureUserIsAdmin;
|
||||
use App\Http\Middleware\EnsureUserIsCustomer;
|
||||
use App\Http\Middleware\LogSlowAdminRequests;
|
||||
|
|
@ -31,6 +32,7 @@ use App\Models\User;
|
|||
use App\Models\WithdrawalRequest;
|
||||
use App\Observers\ActivityLogObserver;
|
||||
use App\Observers\AdminPerformanceCacheObserver;
|
||||
use App\Observers\CompanyMembershipObserver;
|
||||
use App\Observers\CompanyPublicCacheObserver;
|
||||
use App\Observers\EmailDomainObserver;
|
||||
use App\Observers\ImageRightsConfirmationObserver;
|
||||
|
|
@ -243,6 +245,12 @@ class AppServiceProvider extends ServiceProvider
|
|||
Company::observe(EmailDomainObserver::class);
|
||||
Contact::observe(EmailDomainObserver::class);
|
||||
|
||||
// Automatische Firmenrechte sofort neu bewerten, wenn sich ihre
|
||||
// E-Mail-Grundlage ändert (Sicherheitsaudit 07.10.2026, S-03).
|
||||
Company::observe(CompanyMembershipObserver::class);
|
||||
Contact::observe(CompanyMembershipObserver::class);
|
||||
User::observe(CompanyMembershipObserver::class);
|
||||
|
||||
// Öffentliche Seiten-Caches bei Löschung/Deaktivierung einer Firma
|
||||
// verwerfen — auf jedem Löschweg, nicht nur im Admin-Formular (F13).
|
||||
Company::observe(CompanyPublicCacheObserver::class);
|
||||
|
|
@ -266,6 +274,7 @@ class AppServiceProvider extends ServiceProvider
|
|||
Livewire::addPersistentMiddleware([
|
||||
BlockAdminDuringImpersonation::class,
|
||||
EnsureUserIsAdmin::class,
|
||||
EnsureStrongAdminAuthentication::class,
|
||||
EnsureUserIsCustomer::class,
|
||||
LogSlowAdminRequests::class,
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@ use App\Actions\Fortify\CreateNewUser;
|
|||
use App\Actions\Fortify\ResetUserPassword;
|
||||
use App\Actions\Fortify\UpdateUserPassword;
|
||||
use App\Http\Responses\FailedTwoFactorChallengeResponse;
|
||||
use App\Http\Responses\PasskeyLoginResponse;
|
||||
use App\Http\Responses\RoleAwareLoginResponse;
|
||||
use App\Models\User;
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
|
|
@ -17,6 +19,10 @@ use Laravel\Fortify\Contracts\FailedTwoFactorLoginResponse;
|
|||
use Laravel\Fortify\Contracts\LoginResponse;
|
||||
use Laravel\Fortify\Contracts\TwoFactorLoginResponse;
|
||||
use Laravel\Fortify\Fortify;
|
||||
use Laravel\Passkeys\Contracts\PasskeyLoginResponse as PasskeyLoginResponseContract;
|
||||
use Laravel\Passkeys\Contracts\PasskeyUser;
|
||||
use Laravel\Passkeys\Passkey;
|
||||
use Laravel\Passkeys\Passkeys;
|
||||
|
||||
class FortifyServiceProvider extends ServiceProvider
|
||||
{
|
||||
|
|
@ -30,6 +36,7 @@ class FortifyServiceProvider extends ServiceProvider
|
|||
// Fortifys Default leitet auf die nicht registrierte Route
|
||||
// `two-factor.login` (views => false) – zurück zur Livewire-Challenge.
|
||||
$this->app->singleton(FailedTwoFactorLoginResponse::class, FailedTwoFactorChallengeResponse::class);
|
||||
$this->app->singleton(PasskeyLoginResponseContract::class, PasskeyLoginResponse::class);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -54,5 +61,15 @@ class FortifyServiceProvider extends ServiceProvider
|
|||
RateLimiter::for('two-factor', function (Request $request) {
|
||||
return Limit::perMinute(5)->by($request->session()->get('login.id'));
|
||||
});
|
||||
|
||||
RateLimiter::for('passkeys', function (Request $request) {
|
||||
return Limit::perMinute(10)->by($request->user()?->getAuthIdentifier() ?? $request->ip());
|
||||
});
|
||||
|
||||
// Passkey-Login (Entscheidung 5, 07.10.2026): nur aktive, verifizierte
|
||||
// Konten — wie die übrigen Anmeldewege.
|
||||
Passkeys::authorizeLoginUsing(function (Request $request, PasskeyUser $user, Passkey $passkey): bool {
|
||||
return $user instanceof User && $user->is_active && $user->hasVerifiedEmail();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -119,16 +119,22 @@ class ContactAccessService
|
|||
* (`granted_via = manual`) bleiben, ebenso Mitgliedschaften aus der
|
||||
* API-Zuordnung (`granted_via = api`, Audit R-01) — ihre Grundlage ist
|
||||
* die Freischaltung des Einlieferers, nicht die E-Mail.
|
||||
*
|
||||
* Neben Login und E-Mail-Bestätigung ruft auch der
|
||||
* CompanyMembershipObserver diese Methode auf, sobald sich Kontakt- oder
|
||||
* Firmenadressen bzw. Kontaktzuordnungen ändern — der Entzug greift damit
|
||||
* in der laufenden Session (Sicherheitsaudit 07.10.2026, S-03).
|
||||
*/
|
||||
public function syncMembershipsForEmail(User $user): void
|
||||
{
|
||||
if (! $user->is_active || ! $user->hasVerifiedEmail()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$email = mb_strtolower(trim((string) $user->email));
|
||||
|
||||
if ($email === '') {
|
||||
// Ohne nachgewiesene Adresse gibt es keine Grundlage für automatische
|
||||
// Rechte — vorhandene fallen weg (Sicherheitsaudit 07.10.2026, S-03:
|
||||
// der Abgleich läuft jetzt auch außerhalb des Logins).
|
||||
if (! $user->is_active || ! $user->hasVerifiedEmail() || $email === '') {
|
||||
$this->revokeStaleAutomaticGrants($user, collect());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
41
app/Services/Auth/PasswordChanger.php
Normal file
41
app/Services/Auth/PasswordChanger.php
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Auth;
|
||||
|
||||
use App\Enums\SecurityChange;
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\Session\Session;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Einziger Weg für den Passwortwechsel eines angemeldeten Kontos
|
||||
* (Sicherheitsaudit 07.10.2026, S-04). Neben dem neuen Hash wird der
|
||||
* Remember-Token rotiert und jede andere Browser-Session beendet — die
|
||||
* aktuelle bleibt angemeldet. API-Tokens bleiben bewusst bestehen
|
||||
* (Entscheidung 1: Integrationen sollen nicht unangekündigt brechen); der
|
||||
* Hinweis an die Kontoadresse nennt sie.
|
||||
*/
|
||||
class PasswordChanger
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SessionRevocation $sessions,
|
||||
private readonly SecurityNotifier $notifier,
|
||||
) {}
|
||||
|
||||
public function change(User $user, string $plainPassword, ?Session $currentSession = null): void
|
||||
{
|
||||
$user->forceFill([
|
||||
'password' => Hash::make($plainPassword),
|
||||
'remember_token' => Str::random(60),
|
||||
])->save();
|
||||
|
||||
if ($currentSession !== null) {
|
||||
$this->sessions->revokeOthersFor($user, $currentSession);
|
||||
} else {
|
||||
$this->sessions->revokeAllFor($user);
|
||||
}
|
||||
|
||||
$this->notifier->notify($user, SecurityChange::PasswordChanged);
|
||||
}
|
||||
}
|
||||
121
app/Services/Auth/SecurityConfirmation.php
Normal file
121
app/Services/Auth/SecurityConfirmation.php
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Auth;
|
||||
|
||||
use App\Mail\SecurityConfirmationCode;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Facades\Session;
|
||||
|
||||
/**
|
||||
* Frischer Identitätsnachweis vor dauerhaften Sicherheitsänderungen
|
||||
* (Sicherheitsaudit 07.10.2026, S-06): Login-Adresse ändern, 2FA erstmals
|
||||
* einrichten, API-Token erzeugen. Eine bestehende Session allein reicht dafür
|
||||
* nicht.
|
||||
*
|
||||
* Konten ohne Passwort und ohne 2FA (Magic-Link-/Google-Konten) bestätigen
|
||||
* per Einmal-Code an die aktuelle Kontoadresse — wer nur die Session, nicht
|
||||
* aber das Postfach hat, kommt damit nicht weiter. Nach erfolgreichem Nachweis
|
||||
* gilt die Session für FRESH_FOR_SECONDS als bestätigt.
|
||||
*/
|
||||
class SecurityConfirmation
|
||||
{
|
||||
public const SESSION_KEY = 'auth.security_confirmed';
|
||||
|
||||
public const FRESH_FOR_SECONDS = 900;
|
||||
|
||||
public const CODE_VALID_MINUTES = 10;
|
||||
|
||||
public const MAX_CODE_ATTEMPTS = 5;
|
||||
|
||||
public function isFresh(User $user): bool
|
||||
{
|
||||
$confirmed = Session::get(self::SESSION_KEY);
|
||||
|
||||
return is_array($confirmed)
|
||||
&& (int) ($confirmed['user_id'] ?? 0) === (int) $user->getKey()
|
||||
&& (int) ($confirmed['at'] ?? 0) >= now()->getTimestamp() - self::FRESH_FOR_SECONDS;
|
||||
}
|
||||
|
||||
public function markConfirmed(User $user): void
|
||||
{
|
||||
Session::put(self::SESSION_KEY, [
|
||||
'user_id' => $user->getKey(),
|
||||
'at' => now()->getTimestamp(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Schickt einen 6-stelligen Code an die aktuelle Kontoadresse. Liefert
|
||||
* false, wenn zu viele Codes angefordert wurden (3 je 10 Minuten).
|
||||
*/
|
||||
public function sendEmailCode(User $user): bool
|
||||
{
|
||||
$throttleKey = 'security-email-code-send:'.$user->getKey();
|
||||
|
||||
if (RateLimiter::tooManyAttempts($throttleKey, 3)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
RateLimiter::hit($throttleKey, 600);
|
||||
|
||||
$code = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);
|
||||
$expiresAt = now()->addMinutes(self::CODE_VALID_MINUTES);
|
||||
|
||||
Cache::put($this->codeKey($user), [
|
||||
'hash' => $this->hash($code),
|
||||
'attempts' => 0,
|
||||
'expires_at' => $expiresAt->getTimestamp(),
|
||||
], $expiresAt);
|
||||
|
||||
Mail::to($user->email)->queue((new SecurityConfirmationCode(
|
||||
user: $user,
|
||||
code: $code,
|
||||
expiresAt: $expiresAt->format('H:i'),
|
||||
))->afterCommit());
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft den Code; er ist nur einmal gültig und nach
|
||||
* MAX_CODE_ATTEMPTS Fehlversuchen verbraucht.
|
||||
*/
|
||||
public function verifyEmailCode(User $user, string $code): bool
|
||||
{
|
||||
$key = $this->codeKey($user);
|
||||
$entry = Cache::get($key);
|
||||
|
||||
if (! is_array($entry) || (int) ($entry['expires_at'] ?? 0) < now()->getTimestamp()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (hash_equals((string) $entry['hash'], $this->hash(trim($code)))) {
|
||||
Cache::forget($key);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
$entry['attempts'] = (int) $entry['attempts'] + 1;
|
||||
|
||||
if ($entry['attempts'] >= self::MAX_CODE_ATTEMPTS) {
|
||||
Cache::forget($key);
|
||||
} else {
|
||||
Cache::put($key, $entry, now()->setTimestamp((int) $entry['expires_at']));
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function codeKey(User $user): string
|
||||
{
|
||||
return 'security-email-code:'.$user->getKey();
|
||||
}
|
||||
|
||||
private function hash(string $code): string
|
||||
{
|
||||
return hash_hmac('sha256', $code, (string) config('app.key'));
|
||||
}
|
||||
}
|
||||
36
app/Services/Auth/SecurityNotifier.php
Normal file
36
app/Services/Auth/SecurityNotifier.php
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Auth;
|
||||
|
||||
use App\Enums\SecurityChange;
|
||||
use App\Mail\SecurityChangeNotice;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
/**
|
||||
* Informiert den Kontoinhaber über sicherheitsrelevante Änderungen
|
||||
* (Sicherheitsaudit 07.10.2026, S-06). Wer eine fremde Session nutzt, soll
|
||||
* nicht unbemerkt Passwort, Login-Adresse oder zweiten Faktor ändern können.
|
||||
*/
|
||||
class SecurityNotifier
|
||||
{
|
||||
/**
|
||||
* @param string|null $recipient Abweichender Empfänger, z. B. die
|
||||
* bisherige Adresse nach einem E-Mail-Wechsel.
|
||||
*/
|
||||
public function notify(User $user, SecurityChange $change, ?string $recipient = null, ?string $newEmail = null): void
|
||||
{
|
||||
$recipient = trim((string) ($recipient ?? $user->email));
|
||||
|
||||
if ($recipient === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
Mail::to($recipient)->queue((new SecurityChangeNotice(
|
||||
user: $user,
|
||||
change: $change,
|
||||
occurredAt: now()->format('d.m.Y H:i'),
|
||||
newEmail: $newEmail,
|
||||
))->afterCommit());
|
||||
}
|
||||
}
|
||||
|
|
@ -31,13 +31,30 @@ class SessionRevocation
|
|||
/**
|
||||
* Alle bis jetzt entstandenen Sessions des Kontos werden ungültig.
|
||||
*/
|
||||
public function revokeAllFor(User $user): void
|
||||
public function revokeAllFor(User $user): int
|
||||
{
|
||||
$revokedAt = now()->getTimestampMs();
|
||||
|
||||
Cache::put(
|
||||
$this->cacheKey((int) $user->getKey()),
|
||||
now()->getTimestampMs(),
|
||||
$revokedAt,
|
||||
now()->addMinutes(max(1, (int) config('session.lifetime', 120)) + 5),
|
||||
);
|
||||
|
||||
return $revokedAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle anderen Sessions des Kontos werden ungültig, die übergebene
|
||||
* (aktuelle) bleibt angemeldet: Sie erhält einen Anmeldezeitpunkt nach
|
||||
* der Sperre und eine neue Session-ID (Sicherheitsaudit 07.10.2026, S-04).
|
||||
*/
|
||||
public function revokeOthersFor(User $user, Session $current): void
|
||||
{
|
||||
$revokedAt = $this->revokeAllFor($user);
|
||||
|
||||
$current->put(self::SESSION_KEY, $revokedAt + 1);
|
||||
$current->migrate(true);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
90
app/Services/Auth/SocialAccountLinker.php
Normal file
90
app/Services/Auth/SocialAccountLinker.php
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Auth;
|
||||
|
||||
use App\Mail\SocialAccountLinkConfirmation;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Facades\Session;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
|
||||
/**
|
||||
* Bestätigte Erstverknüpfung eines Social-Logins mit einem bestehenden Konto
|
||||
* (Sicherheitsaudit 07.10.2026, S-05, Entscheidung 3).
|
||||
*
|
||||
* Die Provider-Identität wird zunächst nur in der Session vorgemerkt, die
|
||||
* den Login gestartet hat; an die Kontoadresse geht ein signierter Link.
|
||||
* Verknüpft wird erst, wenn dieser Link in DERSELBEN Browser-Session geöffnet
|
||||
* wird. So belegt der Klick den Postfachbesitz, und ein Postfachinhaber, der
|
||||
* eine fremd ausgelöste Mail anklickt, verknüpft nichts.
|
||||
*/
|
||||
class SocialAccountLinker
|
||||
{
|
||||
public const SESSION_KEY = 'oauth.pending_link';
|
||||
|
||||
public const EXPIRES_IN_MINUTES = 30;
|
||||
|
||||
public function __construct(private readonly SocialAuthService $social) {}
|
||||
|
||||
/**
|
||||
* Merkt die Verknüpfung vor und verschickt den Bestätigungslink.
|
||||
* Liefert false, wenn für das Konto zu viele Anfragen kamen.
|
||||
*/
|
||||
public function requestConfirmation(User $user, string $provider, string $providerId): bool
|
||||
{
|
||||
$throttleKey = 'social-link-mail:'.$user->getKey();
|
||||
|
||||
if (RateLimiter::tooManyAttempts($throttleKey, 3)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
RateLimiter::hit($throttleKey, 600);
|
||||
|
||||
$expiresAt = now()->addMinutes(self::EXPIRES_IN_MINUTES);
|
||||
|
||||
Session::put(self::SESSION_KEY, [
|
||||
'user_id' => $user->getKey(),
|
||||
'provider' => $provider,
|
||||
'provider_id' => $providerId,
|
||||
'expires_at' => $expiresAt->getTimestamp(),
|
||||
]);
|
||||
|
||||
$confirmUrl = URL::temporarySignedRoute('oauth.google.confirm-link', $expiresAt, [
|
||||
'user' => $user->getKey(),
|
||||
'provider_id' => $providerId,
|
||||
]);
|
||||
|
||||
Mail::to($user->email)->queue((new SocialAccountLinkConfirmation(
|
||||
user: $user,
|
||||
confirmUrl: $confirmUrl,
|
||||
expiresAt: $expiresAt->format('H:i'),
|
||||
))->afterCommit());
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Speichert die vorgemerkte Verknüpfung, wenn sie zu dieser Session,
|
||||
* diesem Konto und dieser Provider-Identität gehört.
|
||||
*/
|
||||
public function confirm(User $user, string $providerId): bool
|
||||
{
|
||||
$pending = Session::pull(self::SESSION_KEY);
|
||||
|
||||
if (! is_array($pending)
|
||||
|| (int) ($pending['user_id'] ?? 0) !== (int) $user->getKey()
|
||||
|| ! hash_equals((string) ($pending['provider_id'] ?? ''), $providerId)
|
||||
|| (int) ($pending['expires_at'] ?? 0) < now()->getTimestamp()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filled($user->oauth_provider_id)) {
|
||||
return hash_equals((string) $user->oauth_provider_id, $providerId);
|
||||
}
|
||||
|
||||
$this->social->link($user, (string) $pending['provider'], $providerId);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -2,47 +2,79 @@
|
|||
|
||||
namespace App\Services\Auth;
|
||||
|
||||
use App\Enums\SecurityChange;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Auflösung eines Social-Logins (Google, ggf. weitere) auf einen lokalen User.
|
||||
*
|
||||
* Identität ist die E-Mail (De-Dup darüber, keine Dubletten). Der Provider
|
||||
* bestätigt die E-Mail, daher gilt die Verifizierung über den Kanal als erfüllt
|
||||
* (Entscheidung 15.06.) – kein zusätzlicher E-Mail-Verifizierungsschritt.
|
||||
* Reihenfolge (Sicherheitsaudit 07.10.2026, S-05):
|
||||
* 1. Ohne vom Provider bestätigte E-Mail (`email_verified`) kein Login.
|
||||
* 2. Eine bereits verknüpfte Provider-Identität (`oauth_provider_id`) ist
|
||||
* die stabile Bindung und gewinnt vor der E-Mail.
|
||||
* 3. Sonst De-Dup über die E-Mail. Ein Konto, das schon mit einer ANDEREN
|
||||
* Identität desselben Providers verknüpft ist, wird nicht still
|
||||
* umgehängt.
|
||||
* 4. Die Erstverknüpfung eines bestehenden, verifizierten Kontos erfolgt
|
||||
* direkt nur, wenn Google für das Postfach zuständig ist (gmail.com bzw.
|
||||
* Workspace-Domain `hd`). Bei sonstigen Adressen belegt Google den
|
||||
* aktuellen Postfachbesitz nicht — dann erst nach Bestätigungslink an die
|
||||
* Adresse (SocialAccountLinker).
|
||||
*
|
||||
* Neue Konten und noch unverifizierte Selbst-Registrierer werden wie bisher
|
||||
* direkt angelegt bzw. übernommen (Entscheidung 15.06., Audit K-04).
|
||||
*/
|
||||
class SocialAuthService
|
||||
{
|
||||
/**
|
||||
* Domains, deren Postfächer Google selbst betreibt.
|
||||
*/
|
||||
private const GOOGLE_MAIL_DOMAINS = ['gmail.com', 'googlemail.com'];
|
||||
|
||||
public function __construct(
|
||||
private readonly UserRolePermissionSyncService $roleSync,
|
||||
private readonly CustomerAccountProvisioner $accountProvisioner,
|
||||
private readonly SessionRevocation $sessions,
|
||||
private readonly SecurityNotifier $notifier,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Liefert den (ggf. neu angelegten) User zur Social-Identität. Ein
|
||||
* deaktivierter Bestands-Account wird NICHT reaktiviert – der Aufrufer prüft
|
||||
* danach is_active und blockiert.
|
||||
* Ein deaktivierter Bestands-Account wird NICHT reaktiviert – der Aufrufer
|
||||
* prüft danach is_active und blockiert.
|
||||
*/
|
||||
public function resolveUser(string $provider, string $providerId, ?string $email, ?string $name): ?User
|
||||
{
|
||||
public function resolve(
|
||||
string $provider,
|
||||
string $providerId,
|
||||
?string $email,
|
||||
?string $name,
|
||||
bool $emailVerified,
|
||||
?string $hostedDomain = null,
|
||||
): SocialLoginResult {
|
||||
$email = $email ? mb_strtolower(trim($email)) : null;
|
||||
|
||||
if ($email === null || $email === '') {
|
||||
return null;
|
||||
if ($email === null || $email === '' || ! $emailVerified || $providerId === '') {
|
||||
return SocialLoginResult::unverifiedEmail();
|
||||
}
|
||||
|
||||
$linked = User::query()
|
||||
->where('oauth_provider', $provider)
|
||||
->where('oauth_provider_id', $providerId)
|
||||
->first();
|
||||
|
||||
if ($linked) {
|
||||
return SocialLoginResult::login($linked);
|
||||
}
|
||||
|
||||
$user = User::findByEmail($email);
|
||||
|
||||
if (! $user) {
|
||||
return $this->createUser($provider, $providerId, $email, $name);
|
||||
return SocialLoginResult::login($this->createUser($provider, $providerId, $email, $name));
|
||||
}
|
||||
|
||||
$user->forceFill([
|
||||
'oauth_provider' => $provider,
|
||||
'oauth_provider_id' => $providerId,
|
||||
]);
|
||||
if (filled($user->oauth_provider_id)) {
|
||||
return SocialLoginResult::providerMismatch($user);
|
||||
}
|
||||
|
||||
// Noch nicht verifiziert (offener Selbst-Registrierer): der Provider
|
||||
// bestätigt die E-Mail → Onboarding abschließen (aktiv + customer).
|
||||
|
|
@ -54,6 +86,8 @@ class SocialAuthService
|
|||
// oder Magic Link an und setzt bei Bedarf selbst ein Passwort.
|
||||
if ($user->email_verified_at === null) {
|
||||
$user->forceFill([
|
||||
'oauth_provider' => $provider,
|
||||
'oauth_provider_id' => $providerId,
|
||||
'email_verified_at' => now(),
|
||||
'is_active' => true,
|
||||
'password' => null,
|
||||
|
|
@ -68,14 +102,47 @@ class SocialAuthService
|
|||
$this->roleSync->assignRoleAndSyncPermissions($user, 'customer');
|
||||
}
|
||||
|
||||
return $user;
|
||||
return SocialLoginResult::login($user);
|
||||
}
|
||||
|
||||
if (! $this->providerOwnsMailbox($email, $hostedDomain)) {
|
||||
return SocialLoginResult::confirmLink($user);
|
||||
}
|
||||
|
||||
// Bereits verifiziert: nur Provider verknüpfen. is_active bleibt
|
||||
// unangetastet (deaktivierte Accounts werden nicht reaktiviert).
|
||||
$user->save();
|
||||
$this->link($user, $provider, $providerId);
|
||||
|
||||
return $user;
|
||||
return SocialLoginResult::login($user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verknüpft die Provider-Identität mit einem bestehenden Konto und
|
||||
* informiert den Kontoinhaber darüber.
|
||||
*/
|
||||
public function link(User $user, string $provider, string $providerId): void
|
||||
{
|
||||
$user->forceFill([
|
||||
'oauth_provider' => $provider,
|
||||
'oauth_provider_id' => $providerId,
|
||||
])->save();
|
||||
|
||||
$this->notifier->notify($user, SecurityChange::GoogleLinked);
|
||||
}
|
||||
|
||||
/**
|
||||
* Google ist für das Postfach zuständig: eigene Mail-Domain oder eine
|
||||
* Workspace-Domain (`hd`), zu der die Adresse gehört.
|
||||
*/
|
||||
private function providerOwnsMailbox(string $email, ?string $hostedDomain): bool
|
||||
{
|
||||
$domain = Str::afterLast($email, '@');
|
||||
|
||||
if (in_array($domain, self::GOOGLE_MAIL_DOMAINS, true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return filled($hostedDomain) && strcasecmp(trim($hostedDomain), $domain) === 0;
|
||||
}
|
||||
|
||||
private function createUser(string $provider, string $providerId, string $email, ?string $name): User
|
||||
|
|
|
|||
47
app/Services/Auth/SocialLoginResult.php
Normal file
47
app/Services/Auth/SocialLoginResult.php
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Auth;
|
||||
|
||||
use App\Models\User;
|
||||
|
||||
/**
|
||||
* Ergebnis der Auflösung eines Social-Logins (SocialAuthService::resolve()).
|
||||
*/
|
||||
final readonly class SocialLoginResult
|
||||
{
|
||||
public const LOGIN = 'login';
|
||||
|
||||
/** Bestehendes Konto, Verknüpfung erst nach Bestätigung per E-Mail (S-05). */
|
||||
public const CONFIRM_LINK = 'confirm_link';
|
||||
|
||||
/** Keine bzw. eine vom Provider nicht bestätigte E-Mail-Adresse. */
|
||||
public const UNVERIFIED_EMAIL = 'unverified_email';
|
||||
|
||||
/** Konto ist bereits mit einer anderen Identität desselben Providers verknüpft. */
|
||||
public const PROVIDER_MISMATCH = 'provider_mismatch';
|
||||
|
||||
private function __construct(
|
||||
public string $outcome,
|
||||
public ?User $user = null,
|
||||
) {}
|
||||
|
||||
public static function login(User $user): self
|
||||
{
|
||||
return new self(self::LOGIN, $user);
|
||||
}
|
||||
|
||||
public static function confirmLink(User $user): self
|
||||
{
|
||||
return new self(self::CONFIRM_LINK, $user);
|
||||
}
|
||||
|
||||
public static function unverifiedEmail(): self
|
||||
{
|
||||
return new self(self::UNVERIFIED_EMAIL);
|
||||
}
|
||||
|
||||
public static function providerMismatch(User $user): self
|
||||
{
|
||||
return new self(self::PROVIDER_MISMATCH, $user);
|
||||
}
|
||||
}
|
||||
56
app/Services/Auth/StrongAuthentication.php
Normal file
56
app/Services/Auth/StrongAuthentication.php
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Auth;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Session;
|
||||
|
||||
/**
|
||||
* Starke Anmeldung für den Admin-Bereich (Entscheidung 5, 07.10.2026).
|
||||
*
|
||||
* Super-Admins und Admins brauchen eine Session, die mit einem zweiten Faktor
|
||||
* bestätigt wurde: Passkey (Login oder Bestätigung, Benutzerverifizierung per
|
||||
* Biometrie/PIN ist Pflicht) oder Authenticator-Code (2FA-Challenge bzw.
|
||||
* Bestätigung). Magic Link, Google und Passwort allein genügen nicht — sonst
|
||||
* ließe sich die Pflicht über den bequemsten Anmeldeweg umgehen. Die Marke
|
||||
* gilt für die Lebensdauer der Session.
|
||||
*/
|
||||
class StrongAuthentication
|
||||
{
|
||||
public const SESSION_KEY = 'auth.strong';
|
||||
|
||||
public const INTENDED_KEY = 'auth.strong_intended';
|
||||
|
||||
public function isRequiredFor(User $user): bool
|
||||
{
|
||||
if (! config('auth.require_strong_admin_authentication', true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->is_super_admin || $user->hasRole('admin');
|
||||
}
|
||||
|
||||
public function isSatisfiedBy(User $user): bool
|
||||
{
|
||||
$strong = Session::get(self::SESSION_KEY);
|
||||
|
||||
return is_array($strong) && (int) ($strong['user_id'] ?? 0) === (int) $user->getKey();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $method passkey oder totp
|
||||
*/
|
||||
public function mark(User $user, string $method): void
|
||||
{
|
||||
Session::put(self::SESSION_KEY, [
|
||||
'user_id' => $user->getKey(),
|
||||
'method' => $method,
|
||||
'at' => now()->getTimestamp(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function hasPasskeys(User $user): bool
|
||||
{
|
||||
return $user->passkeys()->exists();
|
||||
}
|
||||
}
|
||||
|
|
@ -114,6 +114,21 @@ return [
|
|||
|
||||
'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Starke Anmeldung für Administratoren
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Super-Admins und Admins brauchen für den Admin-Bereich eine Session, die
|
||||
| mit Passkey oder Authenticator-Code bestätigt wurde (Entscheidung 5,
|
||||
| 07.10.2026, EnsureStrongAdminAuthentication). Abschalten nur als
|
||||
| Notfallschalter, z. B. wenn WebAuthn auf der Domain ausfällt. In der
|
||||
| Testsuite aus; die Tests der Funktion schalten sie gezielt ein.
|
||||
|
|
||||
*/
|
||||
|
||||
'require_strong_admin_authentication' => env('AUTH_REQUIRE_STRONG_ADMIN', true),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| HTTP Basic Auth Gate (Staging-Schutz)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<?php
|
||||
|
||||
use App\Http\Middleware\BlockUnusedFortifyRoutes;
|
||||
use App\Http\Middleware\EnsurePasskeyManagementIsConfirmed;
|
||||
use Laravel\Fortify\Features;
|
||||
|
||||
return [
|
||||
|
|
@ -106,7 +107,7 @@ return [
|
|||
|
|
||||
*/
|
||||
|
||||
'middleware' => ['web', BlockUnusedFortifyRoutes::class],
|
||||
'middleware' => ['web', BlockUnusedFortifyRoutes::class, EnsurePasskeyManagementIsConfirmed::class],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
@ -122,6 +123,7 @@ return [
|
|||
'limiters' => [
|
||||
'login' => 'login',
|
||||
'two-factor' => 'two-factor',
|
||||
'passkeys' => 'passkeys',
|
||||
],
|
||||
|
||||
/*
|
||||
|
|
@ -155,12 +157,39 @@ return [
|
|||
// updateProfileInformation() bewusst AUS (Audit F01): der Endpunkt
|
||||
// tauschte die E-Mail sofort und ohne Verifizierung – Firmenrechte
|
||||
// hängen an der Adresse. Die Kunden-UI nutzt den pending_email-Prozess.
|
||||
Features::updatePasswords(),
|
||||
// Features::updatePasswords(), // Abgeschaltet (Sicherheitsaudit 07.10.2026): Passwortwechsel nur über Livewire + PasswordChanger
|
||||
Features::twoFactorAuthentication([
|
||||
'confirm' => true,
|
||||
'confirmPassword' => true,
|
||||
// 'window' => 0,
|
||||
]),
|
||||
// Passkeys (Entscheidung 5, 07.10.2026). Den frischen Nachweis vor dem
|
||||
// Hinzufügen verlangt EnsurePasskeyManagementIsConfirmed statt
|
||||
// `password.confirm` — Letzteres setzt ein Passwort voraus, das
|
||||
// Magic-Link- und Google-Konten nicht haben.
|
||||
Features::passkeys([
|
||||
'confirmPassword' => false,
|
||||
]),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Passkeys
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Login und Sicherheitsseite laufen ausschließlich auf der Portal-Domain
|
||||
| (routes/domains.php) — daran sind die Passkeys gebunden. Bewusst nicht
|
||||
| APP_URL: die zeigt je nach Umgebung auf ein Web-Portal.
|
||||
|
|
||||
*/
|
||||
|
||||
'passkeys' => [
|
||||
'relying_party_id' => env('PASSKEYS_RP_ID', env('APP_PORTAL_NAME', 'pressekonto.test')),
|
||||
'allowed_origins' => [env('APP_PORTAL_URL', 'https://pressekonto.test')],
|
||||
// Eigener Wert empfohlen: ein APP_KEY-Wechsel würde sonst die Kennungen
|
||||
// ändern, unter denen die Geräte die Passkeys gespeichert haben.
|
||||
'user_handle_secret' => env('PASSKEYS_USER_HANDLE_SECRET', env('APP_KEY')),
|
||||
'timeout' => 60000,
|
||||
],
|
||||
|
||||
];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Laravel\Passkeys\Passkeys;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('passkeys', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignIdFor(Passkeys::userModel(), 'user_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('name');
|
||||
$table->string('credential_id')->unique();
|
||||
$table->json('credential');
|
||||
$table->timestamp('last_used_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index('user_id');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('passkeys');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,225 @@
|
|||
# Decision-Update: Changelog und Newsroom-Artikel automatisieren
|
||||
|
||||
*Stand: 04.10.2026 · Projekt Presseportale (pressekonto.com / presseecho.de / businessportal24.com)*
|
||||
|
||||
---
|
||||
|
||||
## 1. Ziel
|
||||
|
||||
Jede Weiterentwicklung des Systems soll öffentlich sichtbar werden, ohne dass daraus eine eigene Schreibaufgabe entsteht. Zwei Ebenen, beide weitgehend aus dem Entwicklungsprozess heraus erzeugt:
|
||||
|
||||
1. **Changelog** auf pressekonto.com: kurz, datiert, vollständig für alles Sichtbare.
|
||||
2. **Newsroom-Artikel** in einer eigenen Rubrik: nur für Neuerungen, die das Verhalten eines Nutzers ändern.
|
||||
|
||||
Der Changelog ist der Beweis, dass das Portal lebt. Für Rückkehrer aus den Reaktivierungswellen ist ein Eintrag von letzter Woche überzeugender als jede Formulierung in der Mail.
|
||||
|
||||
---
|
||||
|
||||
## 2. Bestätigte Entscheidungen
|
||||
|
||||
| Nr. | Entscheidung | Begründung |
|
||||
|---|---|---|
|
||||
| E1 | Zwei getrennte Ebenen (Changelog / Artikel), keine Mischform | Changelog darf granular sein, Artikel müssen selten bleiben |
|
||||
| E2 | Changelog unter pressekonto.com/changelog, datumsbasiert, mit RSS-Feed | Niemand außer Kevin braucht Versionsnummern |
|
||||
| E3 | Rubriken im Changelog: **Neu**, **Verbessert**, **Behoben**, **Sicherheit** | Sicherheit immer nur als Sammelzeile, siehe E5 |
|
||||
| E4 | Erzeugung automatisiert aus Commits mit Kennzeichnung; Kevin liest gegen, streicht, gibt frei | Nebenprodukt der Arbeit statt zusätzlicher Aufgabe |
|
||||
| E5 | Sicherheitskorrekturen erscheinen ausschließlich als „Sicherheitsverbesserungen eingespielt", nur nach dem Deploy, nie mit Beschreibung | Keine Landkarte für Angreifer, keine Verunsicherung für Nutzer |
|
||||
| E6 | Artikel werden automatisiert aus freigegebenen Changelog-Einträgen erzeugt, nach festen Vorgaben (Abschnitt 4), und landen als unveröffentlichter Entwurf im Newsroom | Gleiche Logik wie beim Changelog: Entwurf automatisch, Freigabe manuell |
|
||||
| E7 | Artikel nur bei Schwellenwert „Nutzer macht danach etwas anders"; kleinere Einträge werden zum Monatsrückblick gebündelt | Schutz vor Artikel-Inflation und Selbstdruck |
|
||||
| E8 | Eigene Rubrik „Pressekonto" (Name final offen, siehe O1), eigenes Firmenprofil, klar als Eigenmeldung erkennbar | Keine Vermischung mit Kundenmeldungen |
|
||||
| E9 | Artikel bekommen ein generisches Update-Bild aus eigenem Template (Portal-Logo, Wort „Update", Monat); kein Stockmaterial | Macht auf einen Blick klar, dass es um Portal-Updates geht; keine Bildrechtefrage |
|
||||
| E10 | Eigene Artikel durchlaufen denselben Prüf- und Freigabeweg wie Kundenmeldungen | Glaubwürdigkeit; Scoring-Ergebnis ist zugleich Test des Scorings |
|
||||
| E11 | Dezenter „Neu"-Hinweis im Admin von pressekonto.com, verlinkt auf die letzten Changelog-Einträge | Nutzer sehen Updates dort, wo sie arbeiten |
|
||||
| E12 | Reaktivierungsmails verlinken künftig auf den Changelog statt auf die Startseite | Rückkehrer sehen sofort, was seit dem letzten Login passiert ist |
|
||||
| E13 | Freigegebene Artikel werden automatisch in weitere Kanäle verteilt; Changelog-Einträge einzeln nie | Einzelne Einträge im Social-Feed wirken wie ein Bot |
|
||||
| E14 | Nur kostenlose Kanäle: Bluesky, Mastodon, LinkedIn-Unternehmensseite für pressekonto; X und Threads bleiben außen vor | Kein Budget für Reichweitenexperimente, solange der Nutzen nicht belegt ist |
|
||||
| E15 | Kevins persönliches LinkedIn-Profil ist **nicht** Teil dieser Pipeline | Gehört zum Markenaufbau Adametz Media, ein anderes Projekt mit anderen Regeln |
|
||||
| E16 | Monatlicher E-Mail-Rückblick „Was ist neu" nur per Opt-in-Checkbox im Account, standardmäßig aus | Anti-Zombie: Wer es will, klickt; keine Diskussion über § 7 UWG |
|
||||
| E17 | Benachrichtigungsmails an Pressekontakte bleiben rein informativ, kein Update-Hinweis in der Fußzeile | Sonst kippt die Mail zur Werbung ohne Einwilligung |
|
||||
| E18 | Alle verteilten Links tragen UTM-Parameter; nach drei Monaten wird je Kanal entschieden: behalten oder abschalten | Reichweite wird gemessen, nicht vermutet |
|
||||
|
||||
---
|
||||
|
||||
## 3. Pipeline
|
||||
|
||||
### Stufe 1: Kennzeichnung im Commit
|
||||
|
||||
Nur Commits mit einem Changelog-Trailer werden berücksichtigt. Alles ohne Trailer bleibt intern.
|
||||
|
||||
```
|
||||
feat(newsroom): Firmenprofil mit Newsroom-Ansicht
|
||||
|
||||
Changelog: Neu
|
||||
Public: Unternehmen haben jetzt eine eigene Newsroom-Seite mit allen Meldungen.
|
||||
```
|
||||
|
||||
```
|
||||
fix(upload): Vorschau bricht bei Bildern über 5 MB ab
|
||||
|
||||
Changelog: Behoben
|
||||
Public: Die Bildvorschau funktioniert jetzt auch bei großen Dateien.
|
||||
```
|
||||
|
||||
```
|
||||
fix(auth): Session-Fixation beim Magic-Link
|
||||
|
||||
Changelog: Sicherheit
|
||||
```
|
||||
|
||||
Regeln:
|
||||
- `Changelog:` Wert aus Neu / Verbessert / Behoben / Sicherheit.
|
||||
- `Public:` ein Satz aus Nutzersicht. Fehlt er, formuliert der Generator einen Vorschlag aus dem Commit-Titel; bei **Sicherheit** wird `Public:` ignoriert und immer die Sammelzeile gesetzt.
|
||||
- Interna (Dateinamen, Tabellen, Bibliotheken, Ticketnummern) gehören nie in `Public:`.
|
||||
- Commits hinter einem Feature-Flag bekommen den Trailer erst beim Commit, der das Flag öffnet. Sonst erscheinen Features im Changelog, die noch niemand sieht.
|
||||
|
||||
### Stufe 2: Entwurf beim Deploy
|
||||
|
||||
Deploy-Hook (Ploi) sammelt alle Commits seit dem letzten Release-Tag, filtert auf Trailer, erzeugt pro Rubrik die Einträge und legt sie als **Changelog-Entwurf** mit Datum in der Datenbank ab. Der LLM-Schritt (siehe Vorgaben in Abschnitt 4) glättet nur die Formulierung; er erfindet keine Einträge und fasst mehrere Commits zum selben Feature zu einem Eintrag zusammen. Mehrere Sicherheits-Commits ergeben immer genau eine Sammelzeile.
|
||||
|
||||
### Stufe 3: Review und Freigabe
|
||||
|
||||
Im Admin: Liste der Entwurfseinträge, pro Eintrag streichen / umformulieren / Rubrik ändern / **artikelwürdig** markieren. Freigabe veröffentlicht den Changelog-Eintrag und aktualisiert den RSS-Feed. Zielaufwand: unter fünf Minuten pro Release.
|
||||
|
||||
### Stufe 4: Artikel-Entwurf
|
||||
|
||||
Für jeden als artikelwürdig markierten Eintrag erzeugt der Generator einen Artikel nach den Vorgaben in Abschnitt 4 und legt ihn als unveröffentlichte Meldung im Newsroom an. Bild wird aus dem Template erzeugt (Logo + „Update" + Monat, zwei bis drei Farbvarianten im Wechsel). Ein echter Screenshot kann manuell ergänzt werden, ist aber keine Voraussetzung.
|
||||
|
||||
**Monatsrückblick:** Liegen am Monatsende mindestens [N] freigegebene Einträge ohne eigenen Artikel vor, schlägt das System einen Sammelartikel „Was ist neu im [Monat]" vor.
|
||||
|
||||
### Stufe 5: Prüfung und Veröffentlichung
|
||||
|
||||
Der Artikel durchläuft die automatisierte Qualitätsprüfung wie jede Kundenmeldung. Freigabe manuell. Erstveröffentlichung auf **einem** Portal (siehe O2); eine Zweitveröffentlichung trägt die Referenzkennzeichnung wie bei Kunden.
|
||||
|
||||
---
|
||||
|
||||
## 4. Vorgaben für die Generatoren
|
||||
|
||||
### Changelog-Einträge
|
||||
- Ein bis zwei Zeilen, Nutzersicht, Präsens, Verb am Anfang wo möglich („Zeigt jetzt …", „Behoben: …").
|
||||
- Keine Dateinamen, Tabellen, Bibliotheken, Ticket- oder Commit-IDs, keine internen Projektnamen.
|
||||
- Keine Bewertungen („deutlich schneller", „endlich"), keine Superlative.
|
||||
- Sicherheit: immer exakt „Sicherheitsverbesserungen eingespielt."
|
||||
- Sprache Deutsch. Englische Fassung nur, wenn O5 entschieden ist.
|
||||
|
||||
### Newsroom-Artikel
|
||||
- 300 bis 500 Wörter, Titel ohne Ausrufezeichen, Untertitel mit dem konkreten Nutzen.
|
||||
- Fester Aufbau in Fließtext, ohne Zwischenüberschriften als Labels: Was ist neu, für wen ist es gedacht, wo findet man es, Verweis auf den Changelog.
|
||||
- Ton erklärend, aus Sicht des Nutzers, nüchtern. Keine Marketingvokabeln, keine Versprechen über künftige Features.
|
||||
- Regeln aus „KI-Spuren entfernen" gelten: keine Gedankenstrich-Häufung, keine Dreierfiguren, keine Label-Doppelpunkt-Konstruktionen, keine Nominalisierungsketten.
|
||||
- Der Artikel darf nur beschreiben, was im freigegebenen Changelog-Eintrag steht. Keine Ausschmückung mit Funktionen, die nicht genannt sind.
|
||||
- Pressekontakt, Firmenprofil und Rubrik werden fest gesetzt, nicht generiert.
|
||||
|
||||
---
|
||||
|
||||
## 5. Verteilung in weitere Kanäle
|
||||
|
||||
### Was verteilt wird
|
||||
Ausschließlich freigegebene Newsroom-Artikel und der Monatsrückblick. Quelle ist der RSS-Feed der Rubrik. Changelog-Einträge haben einen eigenen RSS-Feed für Abonnenten, werden aber nicht in soziale Kanäle gespielt.
|
||||
|
||||
### Kanäle (alle kostenlos)
|
||||
|
||||
| Kanal | Weg | Format | Zweck |
|
||||
|---|---|---|---|
|
||||
| Bluesky | n8n, offene API | Kurzfassung bis 300 Zeichen + Link | Deutsche Journalisten und Medienleute, wachsend |
|
||||
| Mastodon | n8n, offene API | Kurzfassung bis 500 Zeichen + Link | Tech- und Medienpublikum, Nullaufwand |
|
||||
| LinkedIn-Unternehmensseite pressekonto | n8n oder Buffer Free | Drei Sätze mit Frage am Ende + Link | Präsenz; Reichweite wird getestet, nicht vorausgesetzt |
|
||||
| E-Mail-Rückblick | Aus dem Monatsartikel, Versand über bestehendes Mailsystem | Monatsartikel als Mail | Retention bei bestehenden Nutzern, nur Opt-in |
|
||||
|
||||
Nicht in der Pipeline: X (API kostenpflichtig), Threads (kein deutsches B2B-Publikum), Kevins persönliches LinkedIn-Profil (E15).
|
||||
|
||||
### Technik
|
||||
- n8n selbst gehostet (Docker auf dem vorhandenen Mac mini), Workflow: RSS-Trigger → pro Kanal eine Kurzfassung erzeugen → posten → Log.
|
||||
- Die Kurzfassungen erzeugt der Generator pro Kanal getrennt, mit leicht unterschiedlicher Formulierung. Identischer Text auf drei Plattformen ist als Kreuzposting sofort erkennbar.
|
||||
- UTM-Schema: `utm_source=<kanal>&utm_medium=social&utm_campaign=update-<jahr>-<monat>`; für die Mail `utm_medium=email`.
|
||||
- Ein Brand-Account pro Kanal (pressekonto), nicht je Portal. Drei Accounts pflegt niemand, auch nicht automatisch.
|
||||
- Zugang zur LinkedIn-Seiten-API setzt eine Freigabe der App durch LinkedIn voraus; dauert erfahrungsgemäß. Fallback ist Buffer im kostenlosen Tarif, der die Seite ohne eigene API-Freigabe bedient.
|
||||
|
||||
### Opt-in für den E-Mail-Rückblick
|
||||
Checkbox im Account: „Monatliche Produkt-Updates per E-Mail erhalten", Standard aus, jederzeit abwählbar, Abmeldelink in jeder Mail. Die Reaktivierungsmails weisen auf die Option hin. Kein Versand an Nutzer ohne gesetzten Haken, auch nicht unter Berufung auf § 7 Abs. 3 UWG.
|
||||
|
||||
---
|
||||
|
||||
## 6. Adversarial Review
|
||||
|
||||
**Commit-Disziplin ist die Sollbruchstelle.** Die Pipeline ist nur so gut wie die Trailer. Wird ein `Public:`-Satz schlampig geschrieben, steht die Schlamperei öffentlich. Gegenmaßnahme ist die Review-Stufe; die darf nie übersprungen werden, auch nicht bei Mini-Releases.
|
||||
|
||||
**Sicherheits-Leak über die Hintertür.** Ein Fix mit `Changelog: Behoben` statt `Sicherheit` beschreibt die Lücke öffentlich. Regel für den Generator: Enthält der Commit-Titel typische Begriffe (auth, session, token, csrf, xss, injection, permission, escape, sanitize, rate limit), wird der Eintrag automatisch auf **Sicherheit** gesetzt und im Review markiert. Zurückstufen nur bewusst.
|
||||
|
||||
**Der Generator halluziniert Features.** Deshalb die harte Regel, dass nur der Changelog-Eintrag Quelle des Artikels ist. Prüfpunkt im Review: Nennt der Artikel etwas, das nicht im Eintrag steht, wird er verworfen, nicht korrigiert.
|
||||
|
||||
**KI-Kennzeichnung der eigenen Texte.** Die Plattform hat eine Kennzeichnung für KI-erstellte Meldungen. Automatisch erzeugte Eigenartikel sind KI-erstellt. Entweder tragen sie die Kennzeichnung, oder Kevin bearbeitet sie vor der Freigabe so, dass redaktionelle Verantwortung erkennbar übernommen wurde. Beides ist vertretbar; was nicht geht, ist bei Kunden kennzeichnen und bei sich selbst nicht. Siehe O4.
|
||||
Antowrt: Ich möchte alle Texte vorher bearbeiten und freigeben. Nichts soll automatisiert online gestellt werden.
|
||||
|
||||
**EU AI Act, Art. 50 Abs. 4.** Für KI-generierte Texte, die veröffentlicht werden, um die Öffentlichkeit zu informieren, gilt ab August 2026 eine Transparenzpflicht; ausgenommen sind Texte unter menschlicher Überprüfung mit redaktioneller Verantwortung. Die manuelle Freigabe deckt das vermutlich ab, aber die Frage gehört auf die Anwaltsagenda, weil sie dieselbe Mechanik betrifft, mit der Kundenmeldungen gekennzeichnet werden. Keine Rechtsberatung, Einordnung.
|
||||
|
||||
**Dünner Content im eigenen Haus.** 30 Changelog-Einträge im Monat sind gut, 30 Artikel wären das Gegenteil dessen, was gegenüber FMI gerade als Qualitätsmaßstab gesetzt wird. Schwellenwert und Monatsrückblick (E7) sind deshalb keine Komfortregeln, sondern Schutz der Positionierung.
|
||||
|
||||
**Behoben-Einträge als Vertrauensfrage.** Eine lange Behoben-Liste nach dem Relaunch kann wirken wie „da war viel kaputt". Das Risiko ist klein gegenüber dem Gewinn an Glaubwürdigkeit, aber die Formulierung sollte beschreiben, was jetzt funktioniert, nicht was vorher nicht ging.
|
||||
|
||||
**Tote Social-Accounts schaden mehr als keine.** Ein Bluesky-Account mit drei Posts aus dem Vorjahr wirkt wie ein verlassenes Portal. Deshalb E18: Nach drei Monaten ohne messbare Besucher wird der Kanal abgeschaltet und der Account gelöscht, nicht stehen gelassen.
|
||||
|
||||
**Automatisierte Posts erzeugen Rückfragen, die niemand beantwortet.** Wer auf Bluesky unter einem Update-Post eine Frage stellt, erwartet eine Antwort. Entweder prüft Kevin die Kanäle einmal pro Woche, oder die Posts enden mit „Fragen gern per Mail an …" und verweisen auf den Kanal, der tatsächlich gelesen wird.
|
||||
|
||||
**Doppelte Rolle auf LinkedIn.** Eine pressekonto-Unternehmensseite und Kevins persönliches Profil sind getrennte Vorhaben, aber dieselbe Plattform. Die Unternehmensseite darf nicht zum Hilfskanal für die persönliche Marke werden und umgekehrt. Trennung gilt für Inhalte, Tonfall und Verantwortung.
|
||||
|
||||
**Startzustand.** Ein leerer Changelog wirkt wie ein leeres Portal. Vor dem Livegang die Relaunch-Features der letzten Wochen als erste Einträge nachtragen (Backfill, datiert auf die tatsächlichen Deploys, soweit rekonstruierbar).
|
||||
|
||||
---
|
||||
|
||||
## 7. Offene Punkte nach Dringlichkeit
|
||||
|
||||
| Nr. | Punkt | Art | Dringlichkeit |
|
||||
|---|---|---|---|
|
||||
| O1 | Name der Rubrik: „Pressekonto", „In eigener Sache" oder „Portal-Updates" | Geschäftsentscheidung | vor Bau |
|
||||
#Portal-Updates
|
||||
| O2 | Portal der Erstveröffentlichung für Eigenartikel (pressekonto.com-Blog oder eines der beiden Portale) | Geschäftsentscheidung | vor Bau |
|
||||
#beide Portale, es wird ja gekennzeichnet per Canonial
|
||||
| O3 | Schwellenwert N für den Monatsrückblick (Vorschlag: 5) | Geschäftsentscheidung | vor Bau |
|
||||
#Ganz klar ein Monatsrückblick. Wenn genug Inhalt auch in einem Monat da ist, sonst kann es auch mal alle zwei Monate sein.
|
||||
|
||||
| O4 | KI-Kennzeichnung bei automatisch erzeugten Eigenartikeln: kennzeichnen oder redaktionell überarbeiten | Geschäftsentscheidung, Rechtsbezug | vor erstem Artikel |
|
||||
#Ich prüfe und überarbeite alle Erzeugn Artikel und gebe sie frei.
|
||||
| O5 | Englische Fassung von Changelog und Artikeln, ja oder nein | Geschäftsentscheidung | Phase 2 |
|
||||
#Hier bitte eine Empfehlung, Ich glaube, wir müssen das aufgrund der Zugriffe entscheiden
|
||||
| O6 | Bild-Template: Farben, Format, Varianten | Gestaltung | vor erstem Artikel |
|
||||
#bereite ich vor, Relativ einfaches Template mit dem Titel mit Logo und dem pressekonto farben
|
||||
| O7 | Backfill der Relaunch-Features als Starteinträge | Umsetzung | vor Livegang |
|
||||
#ja
|
||||
| O8 | Art. 50 AI Act für Eigenmeldungen und Kundenmeldungen | Anwaltsagenda | nächster Anwaltstermin |
|
||||
#ja
|
||||
| O9 | Accounts anlegen: Bluesky, Mastodon (Instanz wählen), LinkedIn-Unternehmensseite pressekonto | Umsetzung | nach erstem Artikel |
|
||||
#Bitte mit in den Entwicklungsplan aufnehmen
|
||||
| O10 | n8n aufsetzen und Workflow RSS → Kanäle bauen; LinkedIn-API-Freigabe beantragen oder Buffer Free nutzen | Umsetzung | nach O9 |
|
||||
#auch in den Entwicklungsplan mit aufnehmen
|
||||
| O11 | Opt-in-Checkbox für den E-Mail-Rückblick im Account, Hinweis in den Reaktivierungsmails | Umsetzung | vor erstem Monatsrückblick |
|
||||
#auf jeden Fall, ihr müsst dann auch noch mal genau sortiert werden. Es gibt ja bereits Abmeldungslisten Etc.
|
||||
| O12 | Wer beantwortet Rückfragen in den Social-Kanälen: wöchentlicher Blick oder Verweis auf E-Mail im Post | Geschäftsentscheidung | vor erstem Post |
|
||||
#
|
||||
| O13 | Impressumspflicht für die Social-Accounts (§ 5 DDG): Link zum Impressum im Profil hinterlegen | Umsetzung | beim Anlegen |
|
||||
#ja siehe pressekonto
|
||||
|
||||
---
|
||||
|
||||
## 8. Revisit-Bedingungen
|
||||
|
||||
- Wenn die Review-Stufe regelmäßig länger als zehn Minuten pro Release dauert: Trailer-Konvention vereinfachen oder Generator-Vorgaben nachschärfen.
|
||||
- Wenn mehr als zwei Artikel im Monat entstehen: Schwellenwert anheben.
|
||||
- Wenn eigene Artikel das Scoring nicht bestehen: zuerst das Scoring prüfen, dann die Vorgaben.
|
||||
- Wenn Nutzer den Changelog nachweislich nicht aufrufen (Zugriffe über drei Monate nahe null): „Neu"-Hinweis im Admin prüfen, nicht den Changelog einstellen.
|
||||
- Wenn O4/O8 anders ausgeht als angenommen: Artikel-Generator pausieren, bis die Kennzeichnung geklärt ist.
|
||||
- Drei Monate nach dem ersten verteilten Artikel: UTM-Auswertung je Kanal. Kanäle ohne messbare Besucher werden abgeschaltet und die Accounts gelöscht.
|
||||
- Wenn ein Kanal kostenpflichtig wird oder seine API schließt: Kanal verlassen, nicht bezahlen.
|
||||
- Wenn die Opt-in-Quote für den E-Mail-Rückblick nach sechs Monaten unter [X] % der aktiven Nutzer liegt: Platzierung der Checkbox prüfen, nicht den Standard auf „an" setzen.
|
||||
|
||||
---
|
||||
|
||||
## 9. Anti-Zombie-Check
|
||||
|
||||
| Grundsatz | Erfüllt? | Anmerkung |
|
||||
|---|---|---|
|
||||
| Keine versteckten Kosten | Ja | Keine Kostenfolge für Nutzer, keine laufenden Kosten für Kevin (E14) |
|
||||
| Transparenz | Ja, verstärkt | Öffentlicher, datierter Changelog ist Transparenz in Reinform |
|
||||
| Kein Dauerbetreuungsaufwand | Ja, wenn Pipeline steht | Review unter fünf Minuten pro Release; Verteilung läuft ohne Eingriff; offen bleibt O12 |
|
||||
| Keine Vermischung von Eigen- und Kundeninteresse | Ja, bei E8 und E10 | Eigene Rubrik, gleicher Prüfweg |
|
||||
| Keine ungefragte Kommunikation | Ja, bei E16 und E17 | Opt-in standardmäßig aus; Pressekontakt-Mails bleiben informativ |
|
||||
| Ehrlichkeit über die eigene Arbeitsweise | Offen, O4 | KI-Kennzeichnung der eigenen Texte muss konsistent zur Kundenregel sein |
|
||||
292
dev/sicherheit/ENTWICKLUNGSPLAN-SICHERHEIT-2026-10-07.md
Normal file
292
dev/sicherheit/ENTWICKLUNGSPLAN-SICHERHEIT-2026-10-07.md
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
# Entwicklungsplan – Behebung Sicherheitsaudit vom 07.10.2026
|
||||
|
||||
**Grundlage:** [SICHERHEITSAUDIT-2026-10-07.md](SICHERHEITSAUDIT-2026-10-07.md), Repository-Stand `2215826`
|
||||
|
||||
**Stand:** 07.10.2026 – Phasen 1–3 und Entscheidung 5 (Passkeys, Pflicht-Zweitfaktor für Admins) umgesetzt, noch nicht committet/deployt; übrige Phase 4 offen
|
||||
|
||||
**Ausführung:** Befehle direkt im Dev-Container (`php artisan …`, ohne Sail – siehe `.ai/rules/general.md`)
|
||||
|
||||
## 0. Umsetzungsstand (07.10.2026)
|
||||
|
||||
Entscheidungen 1–4 wie empfohlen getroffen. Entscheidung 5 umgesetzt als Passkeys plus Pflicht-Zweitfaktor für Admins (siehe unten), Entscheidung 6 siehe unten.
|
||||
|
||||
| ID | Status | Kern der Änderung | Tests |
|
||||
| --- | --- | --- | --- |
|
||||
| S-01 | ✅ umgesetzt | `LegacyInvoicePolicy::view()` verlangt `billing:manage` statt `canAccessAdmin()` | `tests/Feature/Billing/LegacyInvoiceAccessTest.php` |
|
||||
| S-02 | ✅ umgesetzt | `admin/companies/show`: `authorize('companies:write')` in `attachExistingContact()`, `$id` `#[Locked]`, Zuordnungs-UI nur mit Schreibrecht | `tests/Feature/Admin/EditorPermissionBoundaryTest.php` |
|
||||
| S-03 | ✅ umgesetzt | `CompanyMembershipObserver` (Contact/Company/User) ruft `syncMembershipsForEmail()` bei Adress-/Zuordnungsänderung, Löschung, Wiederherstellung; inaktive/unverifizierte Konten verlieren Auto-Rechte; Kommando `memberships:resync-auto` | `tests/Feature/Auth/AutoMembershipRevocationTest.php` |
|
||||
| S-04 | ✅ umgesetzt | `PasswordChanger` (alle Passwortwege, Remember-Token-Rotation, `SessionRevocation::revokeOthersFor()`), Listener `RevokeAccessAfterPasswordReset` (alle Sessions, offene Login-Links), Fortify-Route `PUT /user/password` abgeschaltet | `tests/Feature/Auth/PasswordChangeRevokesSessionsTest.php` |
|
||||
| S-05 A | ✅ umgesetzt | `SocialAuthService::resolve()`: `email_verified` Pflicht, Login zuerst über `oauth_provider_id`, keine stille Umverknüpfung | `tests/Feature/Auth/GoogleLoginTest.php` |
|
||||
| S-05 B | ✅ umgesetzt | `SocialAccountLinker`: Erstverknüpfung bestehender Konten außerhalb gmail/`hd` nur per Bestätigungslink, gebunden an die Browser-Session des Google-Logins | `tests/Feature/Auth/GoogleLoginTest.php` |
|
||||
| S-06 | ✅ umgesetzt | Trait `ConfirmsIdentity` + `SecurityConfirmation` (E-Mail-Code für passwortlose Konten, 15 min Gültigkeit) vor E-Mail-Wechsel, erster 2FA-Einrichtung und Token-Erzeugung; `SecurityChangeNotice` an die (bisherige) Adresse bei Passwort, Reset, E-Mail, 2FA an/aus, Google-Verknüpfung | `tests/Feature/CustomerProfileSecurityTest.php`, `tests/Feature/CustomerPortalTest.php` |
|
||||
|
||||
Komplette Testsuite (inkl. Entscheidung 5): 2403 bestanden, 1 übersprungen. Angepasste Bestandstests: Google-Mocks liefern jetzt `email_verified`; die Tests für 2FA-Einrichtung, Token-Erzeugung und den E-Mail-Wechsel passwortloser Konten senden den neuen Nachweis mit.
|
||||
|
||||
**Entscheidung 6 (07.10.2026): Neue Konten per Google ohne zusätzliche Mail-Bestätigung.**
|
||||
|
||||
- Wird ein neues Konto über Google angelegt, gilt die Bestätigung durch das Google-Konto. Das betrifft auch Adressen ohne gmail oder `hd`.
|
||||
- Das Konto erhält wie bisher direkt die Auto-Rechte an Firmen mit passender Kontakt- oder Firmenadresse.
|
||||
- Bewusst in Kauf genommenes Restrisiko: ein privates Google-Konto mit einer Firmenadresse, deren Postfach der Inhaber nicht mehr kontrolliert.
|
||||
- Dagegen wirkt weiterhin S-03: Entfernt die Firma den Kontakt, enden die Rechte sofort.
|
||||
- Bestehende Konten bleiben durch Entscheidung 3 geschützt.
|
||||
- Abgesichert durch den Bestandstest `a new google user is created active, verified and as customer` in `tests/Feature/Auth/GoogleLoginTest.php`.
|
||||
|
||||
**Entscheidung 5 (07.10.2026): Passkeys und Pflicht-Zweitfaktor für Admins.**
|
||||
|
||||
Bestätigt: offizielles npm-Paket `@laravel/passkeys`, Pflicht für Super-Admins und Admins (nicht für Editoren), Authenticator-App bleibt Alternative.
|
||||
|
||||
- **Grundlage:** Fortify-Feature `passkeys` (Fortify 1.40, bindet `laravel/passkeys` 0.2 ein, also eine frühe Version). Neue Tabelle `passkeys`. Das User-Model implementiert `PasskeyUser`.
|
||||
- **Domain:** Gebunden an die Portal-Domain (`APP_PORTAL_NAME`/`APP_PORTAL_URL`). Nur dort laufen Login und Sicherheitsseite. Für die Kennung lieber ein eigenes `PASSKEYS_USER_HANDLE_SECRET` setzen; ohne Angabe gilt `APP_KEY`, und ein späterer Schlüsselwechsel würde die gespeicherten Passkeys entwerten.
|
||||
- **Login:** Button „Mit Passkey anmelden“ auf der Login-Seite. Die Benutzerverifizierung (Biometrie/PIN) ist Pflicht, ein Passkey ersetzt damit Passwort und App-Code. Nur aktive, verifizierte Konten (`Passkeys::authorizeLoginUsing`). `PasskeyLoginResponse` protokolliert den Login und leitet rollengerecht weiter.
|
||||
- **Sicherheitsseite:** Passkeys hinzufügen, ansehen und entfernen.
|
||||
- Hinzufügen und Entfernen nur mit frischem Nachweis (S-06). Bei der Registrierung prüft `EnsurePasskeyManagementIsConfirmed` den Nachweis zusätzlich an den Fortify-Routen; während einer Impersonation ist beides gesperrt.
|
||||
- Hinweis-Mail an die Kontoadresse.
|
||||
- Ein bestätigter Passkey zählt als Nachweis für Sicherheitsänderungen.
|
||||
- **Admin-Pflicht:** `EnsureStrongAdminAuthentication` an allen Admin-Routen und als persistente Livewire-Middleware.
|
||||
- Super-Admins und Admins bestätigen die Session einmal per Passkey oder Authenticator-Code auf `admin/anmeldung-bestaetigen`.
|
||||
- Magic Link, Google oder Passwort allein genügen nicht.
|
||||
- Ohne zweiten Faktor führt die Seite zur Einrichtung.
|
||||
- Notfallschalter: `AUTH_REQUIRE_STRONG_ADMIN=false`. In der Testsuite ist die Pflicht aus; ihre eigenen Tests schalten sie ein.
|
||||
- **Gerät verloren:** Ein Super-Admin setzt Passkeys und App eines anderen Kontos in `admin/users/{id}/edit` zurück („Zweiter Faktor“). Dabei enden alle Sessions des Kontos, und der Inhaber wird per Mail informiert.
|
||||
- **Tests:** `tests/Feature/Auth/PasskeyAndStrongAdminAuthTest.php`. Die WebAuthn-Zeremonie selbst ist nicht automatisiert getestet; das muss einmal im echten Browser geprüft werden.
|
||||
|
||||
**Release und Deploy (Git-Deploy, siehe `dev/betrieb/git-deploy.md`):**
|
||||
|
||||
Alles zusammen geht als ein Release raus. Vorgeschlagen ist `v1.4.0`: neue Funktion und eine Migration.
|
||||
|
||||
*Release lokal (Abschnitt C):*
|
||||
|
||||
1. `main` muss sauber sein, die volle Suite und Larastan grün. Vorher klären, was mit den unversionierten Ordnern `.ai/` und `dev/2026-10/` passiert (committen oder ignorieren).
|
||||
2. `npm ci && npm run build`, danach `public/build` mitcommitten. Neu ist der Eintrag `passkeys.js`, außerdem kommt `@laravel/passkeys` in `package.json`/`package-lock.json` dazu.
|
||||
3. `git tag -a v1.4.0 -m "v1.4.0 — Sicherheitsaudit S-01–S-06, Passkeys, Zweitfaktor-Pflicht für Admins"` und anschließend `git push origin main --follow-tags` sowie `git push NAS main --follow-tags`.
|
||||
|
||||
*Vor dem Deploy auf Live (Abschnitt D):*
|
||||
|
||||
4. **DB-Backup** ziehen, weil die Migration `create_passkeys_table` mitkommt. Die neue Tabelle ist klein; der Fremdschlüssel auf `users` baut `users` nicht um.
|
||||
5. **`.env` auf Live** vorher ergänzen bzw. prüfen:
|
||||
- `APP_PORTAL_NAME` und `APP_PORTAL_URL` (mit https): An diese Domain sind die Passkeys gebunden.
|
||||
- `PASSKEYS_USER_HANDLE_SECRET`: ein eigener Zufallswert, gesetzt **vor** dem ersten Passkey.
|
||||
- `AUTH_REQUIRE_STRONG_ADMIN` nicht setzen (Standard: an). Es ist nur der Notfallschalter.
|
||||
6. Zahl der automatischen Firmenrechte festhalten (read-only):
|
||||
`SELECT COUNT(*) FROM company_user WHERE role='responsible' AND granted_via IN ('auto','auto-upgrade');`
|
||||
|
||||
*Deploy:*
|
||||
|
||||
7. `bash dev/betrieb/deploy.sh v1.4.0 --migrations`.
|
||||
- Das Skript erledigt Migration, `config:cache` (neue Fortify- und Auth-Einstellungen), `event:cache` (die neuen Listener werden darüber gefunden) und `queue:restart`.
|
||||
- Zum Schluss prüft es den Worker. Der wird gebraucht, weil Hinweis-Mails, Bestätigungscodes und Google-Verknüpfungslinks über die Queue laufen.
|
||||
|
||||
*Nach dem Deploy:*
|
||||
|
||||
8. `php artisan memberships:resync-auto`. Die Ausgabe nennt die Zahl der automatischen Rechte vorher und nachher; sie mit Schritt 6 vergleichen.
|
||||
9. Die üblichen Prüfungen aus Abschnitt D, Punkt 17.
|
||||
10. **Smoke-Test der Neuerungen:**
|
||||
- Als Admin anmelden. Es erscheint „Anmeldung bestätigen“; dort einen Passkey bzw. die App einrichten oder bestätigen.
|
||||
- Abmelden und „Mit Passkey anmelden“ ausprobieren.
|
||||
- Unter „Sicherheit“ einen zweiten Passkey anlegen und wieder entfernen. Dabei kommen die Hinweis-Mails an.
|
||||
11. Einen Eintrag im Protokoll von `dev/betrieb/git-deploy.md` ergänzen.
|
||||
|
||||
*Rollback (Abschnitt E):* `bash dev/betrieb/deploy.sh v1.3.2`. Die Tabelle `passkeys` bleibt dabei stehen; der alte Code nutzt sie nicht. Ein Rollback entfernt auch die Admin-Pflicht. Soll nur die Pflicht weg, reicht der Notfallschalter `AUTH_REQUIRE_STRONG_ADMIN=false` mit anschließendem `php artisan config:cache`.
|
||||
|
||||
**Vorfall 07.10.2026: lokale Datenbank geleert (nicht durch diese Umsetzung).**
|
||||
|
||||
- **Was das Binärlog zeigt:** Um 09:44 UTC (`binlog.000009`, Pos. 566161486) wurden alle Tabellen von `pressekonto` auf `global-mysql` gelöscht, durch ein `migrate:fresh` (`RefreshDatabase`) eines frischen Demo-Projekts. In diesem Projekt wurden die Vorlagen aus `dev/git-deploy-monitoring` getestet (Commit `2215826`).
|
||||
- **Ursache:** `docker-compose.yml` setzte `DB_*` als Container-Umgebungsvariablen. Die haben in Laravel Vorrang vor der `.env`. Die `phpunit.xml` des Demo-Projekts erzwang kein SQLite.
|
||||
- **Unsere Testsuite war nicht beteiligt:** Nach 09:44 gibt es im Binärlog kein weiteres `DROP` oder `TRUNCATE`. Die Gegenprobe mit gesetzten MySQL-Variablen bleibt auf SQLite.
|
||||
- **Behoben:**
|
||||
- `DB_*` aus `docker-compose.yml` entfernt; die Werte stehen identisch in der `.env`. Wirksam erst nach dem Neuerstellen des Containers.
|
||||
- In `dev/git-deploy-monitoring` steht jetzt „Testdatenbank absichern“ als Pflicht vor `RefreshDatabase`, mit `phpunit.xml`-Zeilen, Guard und Gegenprobe.
|
||||
- Agenten-Regel in `.ai/rules/general.md` und `CLAUDE.md` ergänzt.
|
||||
- **Wiederherstellung:** aus einem Backup des Live-Systems (Kevin). Alternativ wäre ein Rückspielen aus dem Binärlog möglich gewesen: Import ab `binlog.000003`, Pos. 380456111, bis zum Löschen. Das Binärlog wird nach 30 Tagen gelöscht.
|
||||
- **Empfehlung:** auf `global-mysql` eigene MySQL-Benutzer je Projekt statt `root`.
|
||||
|
||||
**Offen geblieben:**
|
||||
|
||||
- **Phase 4 (Härtung)** ohne Entscheidung 5: npm-Advisories, Proxy, HSTS, Passwortregeln, dauerhafte Widerrufsmarke, API-Token-Ablauf, Login-Limit, CSP.
|
||||
|
||||
## 1. Nachprüfung des Audits
|
||||
|
||||
Alle sechs Befunde wurden am aktuellen Code nachvollzogen. **Keiner ist ein Fehlalarm.** Bei der Gewichtung gibt es aber Unterschiede zum Audit, weil die Frage „wer kann das überhaupt ausnutzen?“ stärker berücksichtigt wird.
|
||||
|
||||
| ID | Audit | Neu bewertet | Wer kann es ausnutzen | Aufwand | Phase |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| S-01 | Hoch | **Hoch** | Interne Editoren | ~1 h | 1 |
|
||||
| S-02 | Hoch | **Hoch** (zusammen mit S-03) | Interne Editoren | ~1 h | 1 |
|
||||
| S-04 | Hoch | **Hoch** | Jeder mit bestehender Sitzung (z. B. gestohlenes Gerät) | ~3 h | 1 |
|
||||
| S-03 | Hoch | **Hoch** | Ausgeschiedene Mitarbeiter von Kundenfirmen | ~4 h | 2 |
|
||||
| S-05 | Hoch | **Mittel** (Teil A schnell, Teil B Entscheidung) | Inhaber eines alten Google-Kontos mit fremder Firmenadresse | A ~2 h, B ~4 h | 2 / 3 |
|
||||
| S-06 | Mittel | **Mittel** | Jeder mit bestehender Sitzung | ~5 h | 3 |
|
||||
|
||||
### Was sich bei der Nachprüfung gezeigt hat
|
||||
|
||||
- **S-01** – `LegacyInvoicePolicy::view()` gibt allen mit `canAccessAdmin()` frei, also auch Editoren. Die Admin-Route ist zwar durch `can:billing:manage` geschützt, die Kundenroute `/admin/me/legacy-invoices/{id}/pdf` aber nicht. Die IDs sind fortlaufend und lassen sich deshalb durchzählen. Die Policy für neue Rechnungen (`InvoicePolicy`) macht es bereits richtig.
|
||||
- **S-02** – `attachExistingContact()` in `admin/companies/show` prüft keine Berechtigung. Zusätzlich ist `$id` nicht `#[Locked]`. Ein Editor kann damit Kontakte in beliebige Firmen verschieben. **Zusammen mit S-03 wird daraus eine Rechteausweitung:** Ein Kontakt mit einer Adresse, die der Editor selbst kontrolliert, wird in eine fremde Firma verschoben. Beim nächsten Login vergibt `ContactAccessService::linkMemberships()` automatisch die Rolle `responsible` für diese Firma.
|
||||
- Gegencheck: Die anderen Admin-Seiten ohne `can:`-Routengruppe (`users/create`, `users/{id}/edit`, `roles/*`, `contacts/index`) prüfen ihre Rechte in der Komponente selbst. **S-02 ist also ein Einzelfall, kein flächiges Muster.**
|
||||
- **S-03** – `syncMembershipsForEmail()` widerruft veraltete Auto-Rechte korrekt. Aufgerufen wird es aber nur beim Login und bei der Bestätigung einer E-Mail-Änderung. Die Sitzung läuft gleitend (`SESSION_LIFETIME=120` Minuten Inaktivität). **Ein aktiver Nutzer behält die Rechte deshalb praktisch unbegrenzt.** Das typische Szenario: Eine Firma löscht den Pressekontakt einer ausgeschiedenen Mitarbeiterin, und diese arbeitet in ihrer offenen Sitzung weiter in der Firma. Betroffen sind alle Wege, auf denen Kontakte oder Firmenadressen geändert werden: Kunden-Newsroom, Admin-CRM, API und S-02.
|
||||
- **S-04** – Bestätigt für alle drei Wege: Passwort-Reset, Kunden-Sicherheitsseite und `settings/password` (zusätzlich Fortifys `PUT /user/password`, weil `Features::updatePasswords()` aktiv ist). Der passende Mechanismus existiert schon: `SessionRevocation` mit der Middleware `EnsureSessionIsNotRevoked`, die bereits in der `web`-Gruppe hängt. Er wird nur nicht aufgerufen. Beim angemeldeten Passwortwechsel wird außerdem der `remember_token` nicht rotiert.
|
||||
- **S-05** – Bestätigt: Die Zuordnung läuft nur über die E-Mail, `email_verified` wird nicht geprüft, und eine abweichende `oauth_provider_id` wird still überschrieben. **Herabgestuft auf Mittel.** Wer das Postfach tatsächlich kontrolliert (z. B. nach einer neu registrierten Domain), kommt ohnehin per Magic Link hinein; Google ist dann kein zusätzlicher Weg. Ein echtes Mehrrisiko besteht nur in einem Fall: Ein privates Google-Konto wurde mit einer Firmenadresse angelegt, und das Firmenpostfach gibt es nicht mehr. Ein typisches Beispiel ist eine ehemalige PR-Mitarbeiterin.
|
||||
- **S-06** – Bestätigt. Bei Konten ohne Passwort und ohne 2FA prüft `assertSecurityProof()` gar nichts. Wer eine fremde Sitzung hat, kann die Login-Adresse auf ein eigenes Postfach umstellen und das Konto dauerhaft übernehmen. Die Bestätigung geht dabei nur an die neue Adresse. **Die bisherige Adresse erfährt davon nichts:** Es gibt keine Hinweis-Mail, nur `EmailChangeConfirmation` an die neue Adresse.
|
||||
|
||||
### Was weniger dringend ist als es klingt
|
||||
|
||||
- **npm „kritisch“ (`shell-quote`, `source-map-js`)** – Beides sind reine Build-Werkzeuge: `concurrently` für `dev:all` und PostCSS/Tailwind. Sie laufen nur bei `npm ci` / `npm run build` im Deploy und sind aus dem Web nicht erreichbar. Ein schnelles Update lohnt sich trotzdem, ist aber kein Notfall.
|
||||
- **Proxy-Vertrauen `at: '*'`** – Die Host-Whitelist in `bootstrap/app.php` fängt `X-Forwarded-Host`-Missbrauch bereits ab. Offen ist nur die IP-Fälschung über `X-Forwarded-For`. Die ist harmlos, solange der App-Container ausschließlich über Traefik erreichbar ist. Das muss einmal auf dem Server geprüft werden; Code ist dafür vorerst nicht nötig.
|
||||
- **CSP, Sanctum-Token-Ablauf, Login-Rate-Limit pro IP** – Sinnvolle Härtung, aber keine akute Lücke (Phase 4).
|
||||
|
||||
## 2. Entscheidungen vorab (Kevin)
|
||||
|
||||
Diese Punkte bestimmen die Umsetzung von Phase 1 bis 3 mit. Empfehlung jeweils fett.
|
||||
|
||||
1. **API-Tokens beim Passwort-Reset (S-04):** automatisch widerrufen oder bestehen lassen?
|
||||
**Empfehlung: bestehen lassen.** Kunden-Integrationen würden sonst ohne Vorwarnung brechen. Die Erfolgsmeldung und die Hinweis-Mail nennen dafür ausdrücklich „API-Tokens unter Sicherheit prüfen“. Offene Magic Links werden dagegen entwertet.
|
||||
2. **Angemeldeter Passwortwechsel (S-04):** andere Sitzungen immer beenden oder nur auf Wunsch?
|
||||
**Empfehlung: immer.** Das ist der erwartbare Standard und spart eine zusätzliche Option in der Oberfläche.
|
||||
3. **Erstverknüpfung Google mit bestehendem Konto (S-05 Teil B):**
|
||||
**Empfehlung: Bestätigungslink an die Adresse, außer bei `gmail.com`/`googlemail.com` oder gesetztem `hd`.** In diesen Fällen ist Google selbst für das Postfach zuständig. Eine regelmäßige Neubestätigung bereits verknüpfter Konten wird nicht empfohlen: Der Aufwand steht in keinem Verhältnis, und Firmenrechte entzieht ohnehin S-03.
|
||||
4. **Fortify-Route `PUT /user/password`:** Sie wird von keiner Oberfläche benutzt.
|
||||
**Empfehlung: `Features::updatePasswords()` abschalten,** dann gibt es einen Passwortweg weniger.
|
||||
5. **Pflicht-2FA für Admins/Super-Admins (Phase 4):** ja/nein?
|
||||
**Empfehlung: ja, zuerst für Super-Admins.**
|
||||
|
||||
## 3. Phase 1 – Sofort (Rollengrenzen und Kontowiederherstellung)
|
||||
|
||||
Kleine, gut eingrenzbare Änderungen. Zusammen ungefähr ein Arbeitstag mit Tests, als ein Deploy.
|
||||
|
||||
### 1.1 S-01 – Legacy-Rechnungen nur mit `billing:manage`
|
||||
|
||||
- `app/Policies/LegacyInvoicePolicy.php`: In `view()` den Aufruf `canAccessAdmin()` durch `$user->can(Permission::BillingManage)` ersetzen, so wie es `InvoicePolicy` bereits tut. Der Eigentümer-Zweig bleibt.
|
||||
- Weitere Stellen prüfen, die `view`/`downloadPdf` dieser Policy nutzen (z. B. Rechnungsliste in `admin/users/show`). Admins haben `billing:manage`, dort ändert sich also nichts.
|
||||
- **Tests** (neu `tests/Feature/Billing/LegacyInvoiceAccessTest.php` oder Ergänzung in `tests/Feature/Admin/EditorPermissionBoundaryTest.php`):
|
||||
- Editor erhält beim fremden PDF über `me.invoices.pdf` den Status 403, über `admin.legacy-invoices.pdf` ebenfalls 403.
|
||||
- Kunde erhält das eigene PDF mit 200 und ein fremdes mit 403.
|
||||
- Admin mit `billing:manage` erhält das fremde PDF mit 200 auf beiden Routen.
|
||||
|
||||
### 1.2 S-02 – Kontakt-Zuordnung auf der Firmenanzeige absichern
|
||||
|
||||
- `resources/views/livewire/admin/companies/show.blade.php`:
|
||||
- `public int $id` mit `#[Locked]` markieren.
|
||||
- Am Anfang von `attachExistingContact()` den Aufruf `$this->authorize('companies:write')` einfügen. Damit ist auch der Weg über `updatedSelectedExistingContactId()` abgedeckt.
|
||||
- Das Zuordnungs-UI im Template mit `@can('companies:write')` umschließen; das dient nur der Optik, die eigentliche Sperre ist der Server.
|
||||
- Nach dem Umhängen wirkt der S-03-Observer aus Phase 2 automatisch.
|
||||
- **Tests:** Ein Editor ruft `call('attachExistingContact')` auf und setzt `set('selectedExistingContactId', …)`. Beides wird abgewiesen, und `company_id` des Kontakts bleibt unverändert. Ein Admin darf die Aktion weiterhin ausführen. Ein `set('id', …)` wirft eine Exception.
|
||||
|
||||
### 1.3 S-04 – Passwortänderung beendet fremde Sitzungen
|
||||
|
||||
- `app/Services/Auth/SessionRevocation.php` um eine neue Methode `revokeOthersFor(User $user, Session $current)` ergänzen:
|
||||
- `revokeAllFor($user)` aufrufen.
|
||||
- Danach die aktuelle Session mit `SESSION_KEY = revokedAt + 1` neu stempeln und `regenerate()` aufrufen.
|
||||
- Damit bleibt die eigene Sitzung gültig, alle anderen fliegen beim nächsten Request raus. Das gilt auch für Livewire-Requests, weil die Middleware in der `web`-Gruppe hängt.
|
||||
- Einen zentralen Service für den angemeldeten Passwortwechsel anlegen (z. B. `app/Services/Auth/PasswordChanger.php`). Er setzt den Hash, rotiert `remember_token` und ruft `revokeOthersFor()` auf.
|
||||
- Verwendet wird er in `customer/security.blade.php` und `settings/password.blade.php` sowie in `UpdateUserPassword`, falls die Fortify-Route bleibt (Entscheidung 4).
|
||||
- Für den Passwort-Reset einen Listener auf `Illuminate\Auth\Events\PasswordReset` registrieren, analog zu `stampLogin` in `AppServiceProvider`:
|
||||
- `revokeAllFor($user)` aufrufen; der Reset läuft als Gast, deshalb gibt es keine eigene Sitzung zu erhalten.
|
||||
- Offene, unbenutzte `MagicLink`-Datensätze des Kontos entwerten.
|
||||
- Der Listener greift auch beim Go-Live-Reset (`SendGoLiveMails`).
|
||||
- **Tests** (neu `tests/Feature/Auth/PasswordChangeRevokesSessionsTest.php`):
|
||||
- Zwei Sitzungen mit eigenem Login-Stempel aufbauen und in der ersten das Passwort ändern.
|
||||
- Die zweite Sitzung wird auf einer geschützten Seite **und** bei einem Livewire-Update abgemeldet.
|
||||
- Die erste Sitzung bleibt angemeldet; das Ganze für alle Passwortwege prüfen.
|
||||
- Nach dem Reset sind beide Sitzungen ungültig und der offene Magic Link ist nicht mehr einlösbar.
|
||||
- API-Tokens bleiben bestehen, falls Entscheidung 1 so ausfällt.
|
||||
|
||||
**Deploy Phase 1:** keine Migration und kein Nachlauf.
|
||||
|
||||
## 4. Phase 2 – Rechteentzug wirksam machen
|
||||
|
||||
### 2.1 S-03 – Auto-Mitgliedschaften bei jeder Adress- und Zuordnungsänderung neu bewerten
|
||||
|
||||
**Ansatz:** ein Observer statt Aufrufen an jeder einzelnen Stelle. So sind Kunden-Newsroom, Admin-CRM, API, Importer und S-02 auf einmal abgedeckt.
|
||||
|
||||
- Neuer Observer `app/Observers/CompanyMembershipObserver.php`, registriert für `Contact` und `Company` in `AppServiceProvider`.
|
||||
- **Wann er auslöst:**
|
||||
- `Contact::updated`, wenn sich `email` oder `company_id` geändert hat.
|
||||
- `Contact::deleted`.
|
||||
- `Company::updated`, wenn sich `email` geändert hat.
|
||||
- `Company::deleted`.
|
||||
- **Wen er neu bewertet:**
|
||||
- Alle User mit `company_user.granted_via IN ('auto','auto-upgrade')` für die **alte** Firma.
|
||||
- Zusätzlich User, deren E-Mail der **alten oder neuen** Adresse entspricht.
|
||||
- Für jeden davon `ContactAccessService::syncMembershipsForEmail()` aufrufen.
|
||||
- **Umsetzungsdetails:**
|
||||
- Den Observer `afterCommit` ausführen und synchron arbeiten lassen. Die Menge ist klein (Benutzer einer Firma), eine Queue-Verzögerung würde den Entzug wieder aufweichen.
|
||||
- `syncMembershipsForEmail()` bricht bei inaktiven oder unverifizierten Konten früh ab. Für den Entzug ist das unkritisch, weil diese Konten über `canAccessCustomer()` sowieso gesperrt sind. Trotzdem prüfen, ob der Widerruf (`revokeStaleAutomaticGrants`) vor diese Prüfung gezogen werden sollte.
|
||||
- Manuelle (`manual`) und API-Mitgliedschaften (`api`) bleiben unberührt; das stellt `revokeStaleAutomaticGrants()` bereits sicher.
|
||||
- **Bulk-Updates über den Query Builder** lösen keine Model-Events aus. Dafür ein neues Kommando `php artisan memberships:resync-auto [--user=]`. Es synchronisiert alle User mit Auto-Mitgliedschaften neu und dient als Reparatur, analog zu `press-releases:reindex-search`.
|
||||
- **Tests** (neu `tests/Feature/Auth/AutoMembershipRevocationTest.php`):
|
||||
- Kontakt löschen, Kontakt-E-Mail ändern, Kontakt in eine andere Firma verschieben und Firmen-E-Mail ändern.
|
||||
- In jedem dieser Fälle sieht der angemeldete User in **derselben** Sitzung die Firma bzw. ihre PMs nicht mehr (403 oder nicht in der Liste).
|
||||
- Eine manuelle Mitgliedschaft bleibt bestehen.
|
||||
- Eine `auto-upgrade`-Mitgliedschaft fällt auf `member` zurück.
|
||||
- Gibt es eine zweite gültige Grundlage, also einen weiteren Kontakt mit derselben Adresse, bleibt das Recht erhalten.
|
||||
|
||||
**Deploy Phase 2:** Nach dem Deploy einmal `php artisan memberships:resync-auto` ausführen. Das bereinigt den Altbestand, also Auto-Rechte, deren Grundlage schon heute fehlt. Vorher die Zahl der betroffenen Pivots per Read-only-Query festhalten.
|
||||
|
||||
### 2.2 S-05 Teil A – Google-Identität fest binden (ohne Produktentscheidung)
|
||||
|
||||
- `GoogleController::callback()`: `email_verified` aus `$googleUser->user` lesen. Ist der Wert nicht `true`, wird der Login abgebrochen.
|
||||
- `SocialAuthService::resolveUser()`:
|
||||
1. Zuerst nach `oauth_provider = google` **und** `oauth_provider_id` suchen.
|
||||
2. Erst danach über die E-Mail suchen.
|
||||
3. Hat das gefundene Konto bereits eine **andere** Google-ID, wird **nicht** überschrieben. Der Login wird mit einem Hinweis abgewiesen („Bitte per E-Mail-Link anmelden“), das Ereignis wird ohne personenbezogene Details protokolliert.
|
||||
- Den `hd`-Claim (falls vorhanden) mitgeben, damit Teil B ihn nutzen kann.
|
||||
- **Tests:** Google-Mock mit `email_verified=false` bzw. ohne den Claim wird abgewiesen. Eine abweichende Provider-ID wird abgewiesen, und die gespeicherte ID bleibt unverändert. Die bekannte Provider-ID meldet an, auch wenn sich die Google-Mail geändert hat. Dabei wird die lokale E-Mail **nicht** automatisch übernommen.
|
||||
|
||||
## 5. Phase 3 – Frischer Identitätsnachweis
|
||||
|
||||
### 3.1 Gemeinsamer Baustein „Sicherheitsbestätigung“
|
||||
|
||||
Er wird von S-05 Teil B und von S-06 gebraucht, deshalb einmal sauber bauen:
|
||||
|
||||
- **Nachweis je nach Kontoart:**
|
||||
- Konto mit 2FA: TOTP- oder Recovery-Code (wie heute).
|
||||
- Konto mit Passwort: Passwort.
|
||||
- **Konto ohne beides: 6-stelliger Code per Mail an die aktuelle Adresse.** Er gilt 10 Minuten, wird gehasht im Cache gespeichert und ist auf 5 Versuche begrenzt.
|
||||
- Nach erfolgreichem Nachweis setzt der Baustein `auth.security_confirmed_at` in der Session. Für die nächsten 15 Minuten ist dann keine erneute Abfrage nötig, wie bei Laravels `password.confirm`.
|
||||
- Umsetzung als Trait bzw. Service, der `assertSecurityProof()` in `customer/security.blade.php` ersetzt.
|
||||
|
||||
### 3.2 S-06 – Einsatz der Sicherheitsbestätigung
|
||||
|
||||
- **Pflicht vor:**
|
||||
- erstmaliger 2FA-Einrichtung (`enableTwoFactorAuthentication`),
|
||||
- E-Mail-Wechsel (`updateEmail`),
|
||||
- Erzeugung eines API-Tokens (`customer/tokens`).
|
||||
- **Neue Hinweis-Mail an die bisherige Adresse** (z. B. `app/Mail/SecurityChangeNotice.php`) bei:
|
||||
- bestätigtem E-Mail-Wechsel,
|
||||
- aktivierter oder deaktivierter 2FA,
|
||||
- geändertem oder zurückgesetztem Passwort.
|
||||
- Die Hinweis-Mail enthält Zeitpunkt und Art der Änderung sowie „Waren Sie das nicht? → Passwort zurücksetzen / Support“. Sie enthält keine Codes oder Links mit Anmeldewirkung.
|
||||
- **Tests:**
|
||||
- Ohne frische Bestätigung schlagen 2FA-Einrichtung und E-Mail-Wechsel eines passwortlosen Kontos fehl.
|
||||
- Mit gültigem Mail-Code klappt beides.
|
||||
- Ein abgelaufener oder falscher Code und der 6. Versuch werden abgewiesen.
|
||||
- Die Hinweis-Mail geht an die **alte** Adresse.
|
||||
|
||||
### 3.3 S-05 Teil B – Erstverknüpfung bestätigen (nach Entscheidung 3)
|
||||
|
||||
- Existiert ein verifiziertes Konto **ohne** Google-Verknüpfung und ist die Adresse weder `gmail.com`/`googlemail.com` noch mit `hd` versehen, gilt:
|
||||
- Die Google-ID wird nur **vorgemerkt** (Cache, signierter Link, Gültigkeit 30 Minuten).
|
||||
- An die Adresse geht ein Link „Google-Konto verknüpfen“.
|
||||
- Erst nach dem Klick wird `oauth_provider_id` gespeichert und angemeldet.
|
||||
- **Tests:** externe Adresse ohne `hd` → keine Anmeldung, Mail versendet, nach Klick verknüpft. Bei `gmail.com` oder gesetztem `hd` → direkte Verknüpfung wie bisher.
|
||||
|
||||
## 6. Phase 4 – Härtung (nach Bedarf, einzeln einplanbar)
|
||||
|
||||
| Thema | Maßnahme | Aufwand |
|
||||
| --- | --- | --- |
|
||||
| npm-Advisories | `npm update shell-quote source-map-js` (oder `overrides` in `package.json`), danach `npm run build` und `npm audit --omit=dev`. | 15 min |
|
||||
| Proxy | Auf dem Server prüfen, dass der App-Container keinen veröffentlichten Port hat und nur über Traefik erreichbar ist. Dann `trustProxies(at: …)` auf das Docker-Netz begrenzen. | 30 min |
|
||||
| HSTS | Prüfen, ob Traefik HSTS setzt (`curl -sI https://pressekonto.de`). Falls nicht, dort ergänzen. | 15 min |
|
||||
| ~~Pflicht-2FA Admins~~ | ✅ Umgesetzt als Entscheidung 5 (Passkeys oder App, Super-Admins und Admins), siehe Abschnitt 0. | – |
|
||||
| Passwortregeln | In `AppServiceProvider` `Password::defaults(fn () => Password::min(12)->uncompromised())` nur in Produktion setzen. `uncompromised()` fragt HIBP per k-Anonymität ab. | 1 h |
|
||||
| Widerruf dauerhaft | Die Marke aus `SessionRevocation` zusätzlich als Spalte `users.sessions_revoked_at` speichern, damit ein `cache:clear` widerrufene Sitzungen nicht wieder freigibt. `users` hat FULLTEXT, ist aber mit 25 MB klein (siehe CLAUDE.md). | 2 h |
|
||||
| API-Tokens | Optionales Ablaufdatum bei der Erzeugung und Anzeige „zuletzt verwendet“. Kein globales `expiration`, sonst brechen Integrationen. | 2 h |
|
||||
| Login-Limit | Zusätzliches IP-weites Limit (z. B. 50/min), gegen Password-Spraying. | 1 h |
|
||||
| CSP | `Content-Security-Policy-Report-Only` passend zu Livewire/Alpine einführen, Verstöße in GlitchTip sammeln und später scharf schalten. | 1 Tag |
|
||||
|
||||
## 7. Ablauf und Abnahme
|
||||
|
||||
Die Phasen 1–3 und Entscheidung 5 sind zusammen umgesetzt und gehen als **ein Release per Git-Deploy** raus. Release, Deploy, Nachlauf und Rollback stehen in Abschnitt 0 unter „Release und Deploy“. Die restlichen Punkte aus Phase 4 folgen als eigene Releases nach Kapazität.
|
||||
|
||||
**Vor jedem Release:** volle Suite (`php artisan test --compact --parallel`), Larastan, `vendor/bin/pint --dirty --format agent`.
|
||||
|
||||
**Nach dem Deploy** im Audit-Dokument je Befund den Release-Tag nachtragen.
|
||||
276
dev/sicherheit/SICHERHEITSAUDIT-2026-10-07.md
Normal file
276
dev/sicherheit/SICHERHEITSAUDIT-2026-10-07.md
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
# Sicherheitsaudit – Login, Sitzungen und Berechtigungen
|
||||
|
||||
**Datum:** 07.10.2026
|
||||
|
||||
**Geprüfter Repository-Stand:** `2215826f374b`
|
||||
|
||||
**Arbeitsverzeichnis:** `/var/www/html`
|
||||
|
||||
**Auftrag:** ausschließlich Sicherheitsprüfung und Dokumentation; keine Fehlerbehebung.
|
||||
|
||||
## 1. Ergebnis
|
||||
|
||||
Die Anwendung hat bereits wesentliche Schutzmechanismen. Dennoch wurden **sechs konkrete Sicherheitsprobleme im Code** festgestellt: fünf mit hoher und eines mit mittlerer Priorität. Besonders relevant sind der Zugriff von Editoren auf fremde Alt-Rechnungen, eine ungeschützte Kontakt-Schreibaktion, verzögerter Entzug von Firmenrechten, fortbestehende Sitzungen nach Passwortänderungen und die automatische Kontoverknüpfung beim Google-Login.
|
||||
|
||||
Die Befunde sind durch nachvollziehbare Codepfade belegt. **Eine gezielte dynamische Ausnutzung der sechs Befunde wurde nicht durchgeführt.** Nach Klarstellung der Dev-Container-Umgebung wurden die vorhandenen Sicherheitstests und Paketprüfungen direkt ausgeführt; Ergebnisse siehe Abschnitt 6. Zusätzlich wurden zwei npm-Sicherheitsmeldungen für drei betroffene Pakete gefunden. Der Bericht ist keine Bestätigung der Sicherheit des produktiven Gesamtsystems.
|
||||
|
||||
| ID | Priorität | Befund | Nachweisstatus |
|
||||
| --- | --- | --- | --- |
|
||||
| S-01 | Hoch | Editor kann fremde Legacy-Rechnungs-PDFs abrufen | Codepfad belegt |
|
||||
| S-02 | Hoch | Kontaktzuordnung lässt sich ohne `companies:write` ändern | Codepfad belegt |
|
||||
| S-03 | Hoch | Automatische Firmenrechte bleiben nach Kontaktentfernung bestehen | Codepfad belegt |
|
||||
| S-04 | Hoch | Passwortreset/-wechsel widerruft bestehende Browser-Sitzungen nicht | Codepfad belegt |
|
||||
| S-05 | Hoch | Google-Kontoverknüpfung vertraut der E-Mail ohne ausreichende Identitätsprüfung | Fehlende Prüfungen belegt; Angriff erfordert passende Google-Identität |
|
||||
| S-06 | Mittel | Sensible Kontokonfiguration teilweise ohne erneuten Identitätsnachweis | Codepfad belegt; bestehende Sitzung erforderlich |
|
||||
|
||||
„Hoch“ bezeichnet hier erheblichen unberechtigten Datenzugriff, Schreibzugriff oder fortbestehenden Kontozugriff unter den jeweils genannten Voraussetzungen. Die Einstufung ist keine berechnete CVSS-Bewertung. Es wurde kein voraussetzungsloser anonymer Vollzugriff auf das System nachgewiesen.
|
||||
|
||||
## 2. Umfang und Methode
|
||||
|
||||
Geprüft wurden insbesondere:
|
||||
|
||||
- Passwortlogin über Livewire und Fortify; Registrierung und E-Mail-Verifizierung.
|
||||
- Passwortreset, Passwortwechsel, Magic Links, Google-OAuth und Zwei-Faktor-Authentifizierung.
|
||||
- Sitzungsaufbau, Logout, Remember-Cookies und eigener Sitzungswiderruf.
|
||||
- Admin-/Editor-/Kunden-Grenzen, Policies, Firmenmitgliedschaften und ausgewählte Livewire-Schreibaktionen.
|
||||
- Rechnungsdownloads, Benutzer-Impersonation und API-Token-Verwaltung.
|
||||
- API-Authentifizierung, Token-Berechtigungen, Eigentümerfilter und aktive Konten.
|
||||
- Stichproben zu privaten Medien, Uploadvalidierung, HTML-Bereinigung, CSRF, HTTP-Headern und Proxy-Vertrauen.
|
||||
- Vorhandene Tests und sicherheitsrelevante Konfiguration. Geheimnisse wurden nicht in diesen Bericht übernommen.
|
||||
|
||||
Die Prüfung kombinierte Quellcodeanalyse, Nachverfolgung von Aufrufern und Schutzschichten sowie Abgleich mit versionsbezogener Laravel-/Livewire-Dokumentation und offizieller Google-Dokumentation. Kommentare zu früheren Audits wurden nicht als Wirksamkeitsnachweis übernommen; entscheidend war der aktuelle Code.
|
||||
|
||||
Laut Laravel-Boost-Paketinventar: PHP 8.4, Laravel 12.69.3, Fortify 1.40.0, Livewire 4.4.7, Sanctum 4.3.3 und Socialite 5.31.0. Dies ist eine Bestandsaufnahme, keine Aussage zur CVE-Freiheit dieser Versionen.
|
||||
|
||||
Nicht durchgeführt wurden Änderungen an Anwendungscode, Konfiguration, Abhängigkeiten oder bestehenden Benutzer-/Berechtigungsdaten; ebenso keine aktiven Angriffe auf Produktivsysteme. Die Tests verwenden eigene SQLite-In-Memory-Daten. Neben diesem Bericht wurde auf ausdrücklichen Benutzerwunsch eine Projektnotiz unter `.ai/rules/general.md` zur direkten Befehlsausführung im Dev-Container angelegt. Bereits vorhandene unversionierte Dateien unter `dev/2026-10/` wurden nicht verändert.
|
||||
|
||||
## 3. Sicherheitsbefunde
|
||||
|
||||
### S-01 – Editor kann fremde Legacy-Rechnungen herunterladen
|
||||
|
||||
**Priorität: Hoch.** Betroffen ist die Vertraulichkeit von Rechnungs- und Kundendaten.
|
||||
|
||||
**Belege:**
|
||||
|
||||
- `routes/customer.php:21` lässt auch aktive, verifizierte Editoren in den Kundenbereich; `:69` registriert `admin/me/legacy-invoices/{legacyInvoice}/pdf`.
|
||||
- `app/Models/User.php:561` schließt die Rolle `editor` in `canAccessAdmin()` ein.
|
||||
- `app/Policies/LegacyInvoicePolicy.php:22` erlaubt über `canAccessAdmin()` den Zugriff auf jede Alt-Rechnung, unabhängig von Eigentümer und `billing:manage`.
|
||||
- `app/Http/Controllers/LegacyInvoicePdfController.php:15` verwendet diese Policy und liefert anschließend die gespeicherte beziehungsweise neu gerenderte PDF.
|
||||
- Der eigentliche Admin-Rechnungsbereich ist dagegen in `routes/admin.php:95` an `billing:manage` gebunden. Die Editor-Rolle besitzt dieses Recht nach `app/Authorization/Permission.php:98` nicht.
|
||||
|
||||
**Voraussetzung und nachvollziehbarer Prüffall:** Ein aktiver, verifizierter Editor ohne `billing:manage` ruft die Kunden-Downloadroute mit der ID einer fremden Legacy-Rechnung auf. Die Policy gestattet den Abruf. Eine ausgeblendete Rechnungsübersicht verhindert diesen direkten Zugriff nicht.
|
||||
|
||||
**Empfehlung:** Fremde Rechnungen nur mit `billing:manage` freigeben; eigenen Belegzugriff separat erhalten. `app/Policies/InvoicePolicy.php` enthält bereits die entsprechende Unterscheidung für neue Rechnungen. Beide URL-Wege müssen dieselbe fachliche Berechtigung erzwingen.
|
||||
|
||||
**Erforderlicher Regressionstest:** Editor ohne Rechnungsrecht erhält beim fremden Legacy-PDF 403; Eigentümer erhält den eigenen Beleg; berechtigter Billing-Administrator erhält den fremden Beleg. Die Kunden- und Admin-Downloadroute separat prüfen.
|
||||
|
||||
### S-02 – Schreibaktion auf der Firmenanzeige umgeht `companies:write`
|
||||
|
||||
**Priorität: Hoch.** Betroffen sind Datenintegrität und mittelbar Firmenberechtigungen.
|
||||
|
||||
**Belege:**
|
||||
|
||||
- `routes/admin.php:71` macht die Firmenanzeige für Editoren erreichbar. Andere CRM-Schreibrouten werden erst ab `:74` durch `can:companies:write` geschützt.
|
||||
- `resources/views/livewire/admin/companies/show.blade.php:56` definiert die öffentliche Aktion `attachExistingContact()` ohne Gate-/Policy-Prüfung.
|
||||
- Die Aktion überschreibt in `:82` die `company_id` des ausgewählten Kontakts.
|
||||
- Auch das Aktualisieren von `selectedExistingContactId` ruft die Aktion direkt auf (`:23`). Die Ziel-ID `$id` ist eine nicht mit `#[Locked]` gesperrte öffentliche Property (`:13`).
|
||||
|
||||
**Voraussetzung und Prüffall:** Ein Editor ohne `companies:write` öffnet eine Firmenanzeige und löst die Aktion mit einem existierenden Kontakt aus. Die serverseitige Prüfung beschränkt sich auf die Existenz von Firma und Kontakt; das fehlende Schreibrecht wird nicht geprüft.
|
||||
|
||||
**Auswirkung:** Kontakte lassen sich gegen die vorgesehene Rollenbegrenzung zwischen Firmen verschieben. Zusätzlich leitet `app/Services/Auth/ContactAccessService.php:282` aus Kontakt-E-Mail und Firmenzuordnung automatische `responsible`-Mitgliedschaften ab. Ein bereits vorhandener Kontakt mit kontrolliertem Postfach kann deshalb nach der Verschiebung zur Grundlage zusätzlicher Firmenrechte werden. Diese zweite Wirkung setzt einen entsprechenden Kontakt und eine spätere Synchronisierung voraus.
|
||||
|
||||
**Empfehlung:** `companies:write` unmittelbar vor der Mutation erzwingen und den Zugriff auf die beteiligten Datensätze prüfen. Die serverseitige Ziel-ID sperren. Eine reine UI-Beschränkung oder das Sperren der ID allein behebt die fehlende Aktionsautorisierung nicht.
|
||||
|
||||
**Erforderlicher Regressionstest:** Direkter Aktionsaufruf und Property-Update als Editor werden abgewiesen; die bestehende Kontaktzuordnung bleibt unverändert. Berechtigte Administratoren dürfen die Aktion weiterhin ausführen.
|
||||
|
||||
### S-03 – Entfernte Kontakte behalten automatische Firmenrechte
|
||||
|
||||
**Priorität: Hoch.** Betroffen sind Vertraulichkeit und Integrität von Firmendaten und Pressemitteilungen.
|
||||
|
||||
**Belege:**
|
||||
|
||||
- `app/Services/Auth/ContactAccessService.php:123` berechnet automatische Mitgliedschaften neu und entzieht in `:155` veraltete automatische Rechte.
|
||||
- Reguläre Aufrufstellen sind der Login-Listener `app/Listeners/SyncCompanyMembershipsOnLogin.php:28` und die Bestätigung der eigenen neuen E-Mail in `app/Http/Controllers/Auth/ConfirmEmailChangeController.php:46`.
|
||||
- Kontaktänderung und -löschung in `resources/views/livewire/customer/press-kits/show.blade.php:263` beziehungsweise `:277` führen diese Bereinigung nicht aus.
|
||||
- Die registrierten `EmailDomainObserver` und `AdminPerformanceCacheObserver` prüfen Domains beziehungsweise leeren Anzeige-Caches; sie widerrufen keine Mitgliedschaften.
|
||||
- `app/Models/User.php:601` verwendet die weiterhin gespeicherten Firmenzuordnungen. `app/Policies/CompanyPolicy.php:75` akzeptiert die bestehende `responsible`-Rolle weiter als Schreibberechtigung.
|
||||
|
||||
**Voraussetzung und Prüffall:** Ein Benutzer hat ausschließlich über seine Kontaktadresse automatisch Firmenzugriff erhalten und ist bereits angemeldet. Die Kontaktadresse wird entfernt oder geändert; es gibt keine zweite gültige Zuordnungsgrundlage. Mit derselben Sitzung wird anschließend erneut auf die Firma beziehungsweise deren Pressemitteilungen zugegriffen.
|
||||
|
||||
**Auswirkung:** Der automatische Berechtigungsdatensatz bleibt bestehen und erlaubt weiterhin Zugriff, bis eine erneute Synchronisierung erfolgt. Ein aktiver Benutzer kann seinen nächsten Login hinausschieben; das ist kein verlässlicher Zeitpunkt für einen Rechteentzug. Manuell vergebene Rechte sind davon zu unterscheiden und dürfen nicht pauschal gelöscht werden.
|
||||
|
||||
**Empfehlung:** Bei Änderungen/Löschungen von Kontakt- und Firmen-E-Mail-Adressen sowie Kontaktverschiebungen die betroffenen automatischen Mitgliedschaften serverseitig neu bewerten. Den Entzug unmittelbar wirksam machen oder die aktuelle Berechtigungsgrundlage bei Zugriff prüfen. Manuelle und API-basierte Mitgliedschaften gezielt erhalten.
|
||||
|
||||
**Erforderlicher Regressionstest:** Nach Entfernung der einzigen Zuordnungsgrundlage wird der nächste Zugriff aus derselben bestehenden Sitzung abgewiesen. Varianten für E-Mail-Wechsel und Firmenwechsel ergänzen; manuelle Mitgliedschaften müssen erhalten bleiben.
|
||||
|
||||
### S-04 – Passwortreset und Passwortwechsel lassen bestehende Sitzungen bestehen
|
||||
|
||||
**Priorität: Hoch.** Betroffen ist die Wiederherstellung der Kontosicherheit nach kompromittierten Zugangsdaten oder Sitzungen.
|
||||
|
||||
**Belege:**
|
||||
|
||||
- `resources/views/livewire/auth/reset-password.blade.php:44` setzt das Passwort und rotiert den Remember-Token, widerruft aber keine bestehenden Browser-Sitzungen.
|
||||
- `resources/views/livewire/customer/security.blade.php:62`, `resources/views/livewire/settings/password.blade.php:22` und `app/Actions/Fortify/UpdateUserPassword.php:22` ändern das Passwort ohne allgemeinen Sitzungswiderruf.
|
||||
- `app/Services/Auth/SessionRevocation.php:34` bietet bereits einen eigenen Widerrufsmechanismus. Der gefundene Anwendungscode ruft ihn nur im Google-Onboarding eines zuvor unverifizierten Kontos auf (`app/Services/Auth/SocialAuthService.php:65`).
|
||||
- In `bootstrap/app.php` und den geschützten Web-Routen ist kein allgemeines `auth.session`/`AuthenticateSession` eingerichtet. Auch ein entsprechender eigener `PasswordReset`-Listener wurde nicht gefunden. Der Eintrag in `config/sanctum.php` ersetzt diese Web-Middleware nicht.
|
||||
|
||||
**Voraussetzung und Prüffall:** Zwei Browser sind mit demselben Konto angemeldet. In einem Browser wird das Passwort zurückgesetzt oder geändert. Die bereits authentifizierte Sitzung des zweiten Browsers bleibt durch die untersuchten Schutzschichten gültig.
|
||||
|
||||
**Auswirkung:** Ein Angreifer mit bestehender Sitzung kann nach der Passwortwiederherstellung weiterarbeiten. Die Rotation des Remember-Tokens beim Reset verhindert nicht die Verwendung einer bereits aufgebauten Sitzung. API-Tokens und noch gültige Login-Links werden ebenfalls nicht zentral als Wiederherstellungsmaßnahme behandelt; deren gewünschter Widerruf muss ausdrücklich festgelegt werden.
|
||||
|
||||
**Empfehlung:** Passwortreset mit einem zentralen Widerruf älterer Sitzungen verbinden. Beim angemeldeten Passwortwechsel mindestens andere Sitzungen beenden oder eine ausdrückliche, wirksame Option anbieten. Alle Passwortwege vereinheitlichen. Für einen vollständigen Kontowiederherstellungsprozess zusätzlich Remember-Cookies, API-Tokens und offene Login-Links berücksichtigen. Laravel beschreibt den erforderlichen Zusammenhang von `auth.session` und `logoutOtherDevices()` in der [Authentifizierungsdokumentation](https://laravel.com/docs/12.x/authentication#invalidating-sessions-on-other-devices).
|
||||
|
||||
**Erforderlicher Regressionstest:** Zwei unabhängig aufgebaute Sitzungen verwenden; nach Reset darf die alte Sitzung keine geschützte Seite und keine Livewire-Aktion mehr ausführen. Alle drei Passwortwechselwege prüfen. Gewolltes Verhalten für die aktuelle Sitzung, Remember-Cookies und API-Tokens separat festhalten.
|
||||
|
||||
### S-05 – Google-Login verknüpft Konten ausschließlich anhand der E-Mail
|
||||
|
||||
**Priorität: Hoch.** Eine Kontoverwechslung kann zum Zugriff auf ein bestehendes Konto und dessen Firmenrechte führen.
|
||||
|
||||
**Belege:**
|
||||
|
||||
- `app/Http/Controllers/Auth/GoogleController.php:35` übergibt lediglich Provider, Provider-ID, E-Mail und Name. `email_verified` und `hd` werden nicht geprüft.
|
||||
- `app/Services/Auth/SocialAuthService.php:36` sucht das lokale Konto ausschließlich anhand der E-Mail.
|
||||
- Eine vorhandene `oauth_provider_id` wird in `:42` überschrieben, statt als stabile Identitätsbindung geprüft zu werden.
|
||||
- Neue beziehungsweise zuvor unverifizierte Konten werden anschließend als verifiziert behandelt.
|
||||
- Der installierte Socialite-Google-Provider stellt `email_verified` in den Rohdaten bereit, erzwingt beim Mapping aber keine entsprechende Anwendungsentscheidung (`vendor/laravel/socialite/src/Two/GoogleProvider.php`, `mapUserToObject()`).
|
||||
|
||||
**Angriffsvoraussetzung:** Der Angreifer benötigt eine von Google gelieferte Identität mit einer E-Mail, die zum lokalen Zielkonto passt, obwohl ihm das zugehörige Postfach nicht mehr gehört oder die erforderliche Verifizierung fehlt. Das ist insbesondere bei externen, später neu vergebenen E-Mail-Adressen relevant. Ein frei erfundener Callback-Parameter reicht nicht aus; Socialite verwendet weiterhin den regulären OAuth-Ablauf.
|
||||
|
||||
**Einordnung:** Google warnt ausdrücklich, dass es für externe E-Mail-Adressen ohne Workspace-Zuordnung (`hd`) keine verlässliche Aussage über den aktuellen Postfachbesitz trifft. Auch `email_verified=true` kann dort auf einer früheren Prüfung beruhen. Die Anwendung muss für solche Verknüpfungen zusätzliche Nachweise verlangen. Siehe [Google: Backend-Authentifizierung](https://developers.google.com/identity/sign-in/web/backend-auth).
|
||||
|
||||
**Auswirkung und Grenze:** Ohne aktivierte 2FA kann eine passende fremde Google-Identität den ersten Loginfaktor ersetzen. Bei bereits bestätigter 2FA wird im geprüften Controller weiterhin die Challenge verlangt; ein pauschaler 2FA-Bypass wurde nicht gefunden.
|
||||
|
||||
**Empfehlung:** Bestehende Verknüpfungen anhand der stabilen Provider-Identität auflösen und abweichende IDs nicht still überschreiben. `email_verified` explizit validieren. Für die erstmalige Verknüpfung mit einem vorhandenen Konto und für nicht autoritative externe Adressen einen aktuellen Nachweis über das lokale Konto beziehungsweise Postfach verlangen. Den Provider-Link erst nach erfolgreicher Prüfung speichern.
|
||||
|
||||
**Erforderlicher Regressionstest:** Fehlendes/falsches `email_verified`, externe Adresse ohne `hd`, bereits anders verknüpfte Provider-ID und wiedervergebene E-Mail als Negativfälle abdecken. Die vorhandenen Google-Tests simulieren überwiegend nur ID/E-Mail/Name und belegen diese Grenzen nicht.
|
||||
|
||||
### S-06 – Sensible Kontokonfiguration ohne frischen Identitätsnachweis
|
||||
|
||||
**Priorität: Mittel.** Voraussetzung ist eine bereits verwendbare Sitzung, etwa nach Session-Diebstahl oder an einem unbeaufsichtigten Gerät.
|
||||
|
||||
**Belege und zwei konkrete Fälle:**
|
||||
|
||||
1. `resources/views/livewire/customer/security.blade.php:138` beginnt die erstmalige 2FA-Einrichtung ohne erneute Passwort-/Postfachbestätigung. `:150` bestätigt lediglich den gerade neu erzeugten Faktor. Die Route in `routes/customer.php:76` verlangt keine frische Authentifizierung. Ein Sitzungsinhaber kann bei bisher fehlender 2FA deshalb seinen eigenen Authenticator registrieren und den echten Nutzer bei künftigen Logins aussperren.
|
||||
2. `resources/views/livewire/customer/security.blade.php:206` lässt bei Konten ohne Passwort und ohne aktive 2FA `assertSecurityProof()` ohne weiteren Nachweis passieren. Der nachfolgende E-Mail-Wechsel bestätigt nur die neue Adresse. Ein Angreifer mit einer solchen Sitzung kann seine eigene neue Adresse bestätigen und daraus dauerhaften Kontozugriff gewinnen, ohne das bisherige Postfach zu kontrollieren.
|
||||
|
||||
Bei schon aktiver 2FA verlangt der Code für Deaktivierung und Recovery-Code-Neuerzeugung einen zweiten Faktor; diese vorhandene Schutzmaßnahme wird hier nicht als fehlend bewertet. Ebenso handelt es sich nicht um einen belegten CSRF-Angriff: Benötigt wird eine gültige Sitzung mit nutzbarem Anwendungskontext.
|
||||
|
||||
**Empfehlung:** Vor erstmaliger 2FA-Einrichtung und Änderung der Login-Adresse einen zeitnahen Nachweis verlangen. Bei passwortlosen Konten etwa einen separaten kurzlebigen Bestätigungslink an die bisherige Adresse oder einen ausdrücklich dafür vorgesehenen erneuten Google-Login. Ein Code des gerade neu eingerichteten Authenticator ersetzt diesen Nachweis nicht. Sicherheitsänderungen zusätzlich an die bisherige Adresse melden.
|
||||
|
||||
**Erforderlicher Regressionstest:** Eine ältere, vollständig angemeldete Sitzung ohne frischen Nachweis darf weder erstmals 2FA aktivieren noch die Login-Adresse eines passwortlosen Kontos ersetzen. Nach erfolgreicher erneuter Identitätsprüfung müssen legitime Änderungen möglich bleiben.
|
||||
|
||||
## 4. Zusätzliche Risiken und Sicherheitshärtung
|
||||
|
||||
Diese Punkte werden ausdrücklich nicht als weitere dynamisch bestätigte Sicherheitslücken gezählt.
|
||||
|
||||
| Thema | Beobachtung | Verbesserung / noch erforderlicher Nachweis |
|
||||
| --- | --- | --- |
|
||||
| Proxy-Vertrauen | `bootstrap/app.php` vertraut allen Proxies einschließlich `X-Forwarded-For` und `X-Forwarded-Host`. | Vertrauenswürdige Proxy-Netze begrenzen oder nachweisen, dass nur der Proxy das Backend erreicht und fremde Forwarded-Header zuverlässig bereinigt. Andernfalls sind IP-basierte Limits und IP-Protokolle manipulierbar. Die reale Produktions-Proxykonfiguration wurde nicht geprüft. |
|
||||
| Login-Rate-Limit | Livewire-Login und Fortify begrenzen nach Kombination aus E-Mail und IP. | Zusätzliche IP-weite und geeignete kontobezogene Erkennung für Password-Spraying/verteilte Versuche erwägen, ohne einfach provozierbare Kontosperren zu erzeugen. Wirksamkeit am tatsächlichen Proxy prüfen. |
|
||||
| 2FA für privilegierte Rollen | 2FA ist unterstützt, aber im untersuchten Admin-Gate nicht verpflichtend. | Für Administratoren und besonders Super-Administratoren verpflichtende 2FA und einen geregelten Wiederherstellungsprozess vorsehen. |
|
||||
| Passwortqualität | Die geprüften Formulare verwenden `Password::defaults()`; eine zentrale Verschärfung wurde nicht gefunden. | Längere Passphrasen, passende Mindestlänge und Prüfung bekannter kompromittierter Passwörter erwägen. Den Schutz nicht nur an Sonderzeichenregeln festmachen. |
|
||||
| API-Token-Lebensdauer | `config/sanctum.php` setzt `expiration` auf `null`; die Kunden-Token-Erzeugung setzt kein Ablaufdatum. | Ablauf, Rotation und Widerruf bei Sicherheitsvorfällen festlegen. Für Token-Erzeugung einen frischen Identitätsnachweis erwägen. Keine Behauptung, dass jeder bereits gespeicherte Token unbegrenzt gültig ist. |
|
||||
| Widerruf im Cache | `SessionRevocation` speichert die Widerrufsmarke befristet im Cache. | Verhalten bei Cache-Flush, Ausfall und Eviction prüfen; gegebenenfalls dauerhafte Authentifizierungsversion am Benutzer vorsehen. Ein Cacheverlust sollte entzogene Sitzungen nicht wieder freigeben. |
|
||||
| HTTP-Header | `SecurityHeaders` setzt mehrere sinnvolle Header, aber keine CSP oder HSTS. | Eine zu Livewire/Alpine passende CSP zunächst beobachtend einführen; HSTS und HTTPS-Zwang am produktiven Einstieg prüfen. Mögliche zusätzliche Proxy-Header sind unbekannt. |
|
||||
| Cookies und Debug | Die lokale `.env` ist ausdrücklich `local` mit Debug; Secure-Cookie ist in `config/session.php` umgebungsabhängig. | Produktivkonfiguration separat prüfen: Debug aus, Secure/HttpOnly/SameSite passend, enge Cookie-Domain, TLS und sichere Session-Speicherung. Lokale Debugwerte sind kein Beleg für eine Produktionslücke. |
|
||||
| Sicherheitsereignisse | Login-/Fehlversuchsprotokollierung ist vorhanden; ein vollständiger zentraler Widerrufsprozess wurde nicht gefunden. | Meldungen zu E-Mail-, Passwort- und 2FA-Änderungen, nachvollziehbare Rechteänderungen sowie eine Funktion zum Beenden fremder Sitzungen vorsehen. Tokens und Kennwörter niemals protokollieren. |
|
||||
|
||||
## 5. Vorhandene Schutzmaßnahmen
|
||||
|
||||
Folgende Maßnahmen sind im geprüften Code positiv erkennbar; Umfang und Grenzen der ausgeführten Tests stehen in Abschnitt 6:
|
||||
|
||||
- Passwort-, Magic-Link- und Google-Login berücksichtigen bestätigte 2FA. Unbenutzte Fortify-2FA-Verwaltungsrouten werden gezielt gesperrt.
|
||||
- Magic Links enthalten zufällige Tokens, speichern deren Hash und prüfen Ablauf sowie Verwendung. Beim Einlösen schützt ein bedingtes Update gegen parallele Wiederverwendung.
|
||||
- Passwortreset-Anforderungen verwenden eine neutrale Rückmeldung und Limits pro E-Mail sowie IP.
|
||||
- Die reguläre E-Mail-Änderung verwendet `pending_email` und einen signierten Bestätigungslink; Übernahme und Firmenabgleich erfolgen erst bei Bestätigung. S-06 betrifft den davor fehlenden erneuten Nachweis für bestimmte Konten.
|
||||
- Admin- und Kunden-Gates berücksichtigen den Aktivstatus. Eigene Panel-Middleware wird für Livewire-Folgeanfragen erneut registriert.
|
||||
- Die API verlangt Sanctum-Authentifizierung und aktive Konten; geprüfte Controller/Requests verwenden Token-Abilities und Eigentümerfilter. API-Schlüssel aus URL-/Body-Parametern werden nicht als Login akzeptiert.
|
||||
- Die Token-Oberfläche beschränkt Abilities, bindet Widerruf an den angemeldeten Benutzer und blockiert Token-Verwaltung während Impersonation.
|
||||
- Impersonation enthält einen Rangschutz für Super-Administratoren und blockiert den Admin-Bereich während der Übernahme.
|
||||
- Private Pressemedien verlangen Berechtigung oder eine gültige signierte Vorschau-URL. HTML-Inhalte werden über einen Purifier bereinigt; geprüfte Bild-Uploads schließen SVG aus und begrenzen Dateitypen/Größe.
|
||||
- CSRF ist regulär aktiv. Die ausdrücklich ausgenommenen Abmeldewege verwenden signierte Links.
|
||||
- Logout invalidiert die Sitzung und erneuert das CSRF-Token. `.env` und `auth.json` sind nicht versioniert und werden durch `.gitignore` ausgeschlossen. Das ist keine vollständige Prüfung der Git-Historie auf Geheimnisse.
|
||||
|
||||
## 6. Verifikation und Einschränkungen
|
||||
|
||||
### Tatsächlich durchgeführt
|
||||
|
||||
- Statische Prüfung der genannten Routen, Komponenten, Services, Policies, Middleware und relevanter installierter Framework-Bestandteile.
|
||||
- Abgleich vorhandener Tests, insbesondere `tests/Feature/Auth/`, `tests/Feature/SecurityHardeningTest.php` sowie der Schutzlogik in `tests/TestCase.php` und `phpunit.xml`.
|
||||
- Prüfung des vorgesehenen Testdatenbankschutzes: SQLite `:memory:` und zusätzlicher Guard vor `RefreshDatabase`; kein Testlauf wurde gestartet, der Bestandsdaten verändert hat.
|
||||
- Laravel-Boost-Paketinventar und Dokumentationssuche; offizieller Google-Dokumentationsabgleich für S-05.
|
||||
- Kontrolle des Repository-Status vor Erstellung des Berichts.
|
||||
|
||||
### Korrektur zur Ausführungsumgebung
|
||||
|
||||
Die erste Prüfung verwendete entsprechend der ursprünglichen Projektanweisung Sail. Die folgenden Aufrufe endeten mit `Docker or Podman is not running.`:
|
||||
|
||||
```bash
|
||||
vendor/bin/sail artisan route:list --path=login -vv --no-interaction
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Auth tests/Feature/SecurityHardeningTest.php tests/Feature/ApiAccessSecurityAndLoggingTest.php
|
||||
vendor/bin/sail composer audit --locked --no-interaction
|
||||
vendor/bin/sail npm audit --omit=dev
|
||||
```
|
||||
|
||||
Diese Meldung belegt nicht, dass Docker auf dem Host gestoppt ist. Die Arbeitsumgebung befindet sich bereits im Dev-Container; Befehle werden dort direkt ausgeführt. Die Korrektur wurde auf Benutzerwunsch dauerhaft in `.ai/rules/general.md` festgehalten. Tests und Paketprüfungen wurden anschließend ohne Sail erneut gestartet. Die Tests wurden für diesen reinen Audit weder erweitert noch verändert. Die bei den Befunden beschriebenen Regressionstests bleiben Empfehlungen für eine spätere Behebung.
|
||||
|
||||
### Erfolgreiche Testausführung – Nachprüfung vom 07.10.2026
|
||||
|
||||
**Insgesamt: 189 Tests bestanden, 920 Assertions, keine fehlgeschlagenen Tests in diesen beiden ausgewählten Läufen.** Die gesamte Anwendungstestsuite wurde nicht ausgeführt.
|
||||
|
||||
| Prüfumfang | Ergebnis | Dauer |
|
||||
| --- | --- | --- |
|
||||
| Authentifizierung, SecurityHardening, API-Zugriff/-Logging | 118 bestanden, 578 Assertions | 14,45 s |
|
||||
| Editor-Berechtigungsgrenzen, Impersonation, Kundenprofil/-Sicherheit, Settings, Firmenkontext und PM-Firmenscope | 71 bestanden, 342 Assertions | 28,95 s |
|
||||
|
||||
Ausgeführte Befehle (jeweils Exit-Code 0):
|
||||
|
||||
```bash
|
||||
php artisan test --compact tests/Feature/Auth tests/Feature/SecurityHardeningTest.php tests/Feature/ApiAccessSecurityAndLoggingTest.php
|
||||
php artisan test --compact tests/Feature/Admin/EditorPermissionBoundaryTest.php tests/Feature/Admin/UserImpersonationTest.php tests/Feature/CustomerProfileSecurityTest.php tests/Feature/Settings tests/Feature/PressReleaseCompanyScopeTest.php tests/Feature/CustomerCompanyContextTest.php
|
||||
php artisan route:list --path=login -vv --no-interaction
|
||||
```
|
||||
|
||||
Die Routenabfrage bestätigt drei Login-bezogene Routen einschließlich Fortify-POST-Login, Livewire-Loginseite und Magic-Link-Einlösung mit den registrierten Middleware-Ketten.
|
||||
|
||||
**Einordnung:** Bestandene vorhandene Tests widerlegen die sechs Codebefunde nicht. Beispielsweise decken Tests für gesperrte Editor-Verwaltungsseiten nicht automatisch den fremden Legacy-PDF-Abruf über die Kundenroute oder das Umhängen eines Kontakts auf der Firmenanzeige ab. Die in Abschnitt 3 benannten spezifischen Angriffsszenarien wurden nicht als neue Tests implementiert. Die Codebefunde bleiben offen; der Nachtrag belegt die erfolgreiche Ausführung der vorhandenen Schutztests.
|
||||
|
||||
### Paket-Sicherheitsaudits – Nachprüfung vom 07.10.2026
|
||||
|
||||
`composer audit --locked --no-interaction` wurde erfolgreich ausgeführt (Exit-Code 0): **keine bekannten Security-Advisories für die geprüften gesperrten PHP-Abhängigkeiten gemeldet**. Dies ist eine zeitpunktbezogene Advisory-Abfrage, keine Garantie für Fehlerfreiheit.
|
||||
|
||||
`npm audit --omit=dev` wurde ausgeführt und meldet **3 betroffene Pakete: 2 kritisch, 1 hoch** (Exit-Code 1 wegen der Funde). Dahinter stehen zwei unterschiedliche Advisories; `concurrently` wird wegen seiner betroffenen Abhängigkeit zusätzlich gezählt:
|
||||
|
||||
| Paket im geprüften Bestand | Einstufung laut Audit | Sicherheitsmeldung | Gepatchte Version laut Advisory |
|
||||
| --- | --- | --- | --- |
|
||||
| `shell-quote@1.9.0` | Kritisch | Kommandoinjektion in `quote()` bei bestimmten Kombinationen von Kommentar-Token und nicht vertrauenswürdigem String mit Zeilenumbruch; [GHSA-pqg4-j6r4-53mv](https://github.com/advisories/GHSA-pqg4-j6r4-53mv) | `shell-quote` 1.11.0 |
|
||||
| `concurrently@9.2.4` | Kritisch, transitiv | Verwendet das betroffene `shell-quote`; dieselbe Meldung, keine dritte eigenständige Schwachstelle | Abhängigkeitskette auf eine Version mit korrigiertem `shell-quote` aktualisieren |
|
||||
| `source-map-js@1.2.1` | Hoch | Blockieren der Event Loop durch präparierte Section-Offsets in indizierten Source Maps; [GHSA-68fv-2mgg-jv7q](https://github.com/advisories/GHSA-68fv-2mgg-jv7q) | `source-map-js` 1.2.2 |
|
||||
|
||||
Die installierten Versionen und Abhängigkeitswege wurden mit `npm ls shell-quote concurrently source-map-js --all` sowie `package-lock.json` abgeglichen. `concurrently` wird laut `package.json` für `dev:all` verwendet; `source-map-js` hängt an der CSS-/Build-Werkzeugkette. Diese Werkzeuge stehen teilweise unter `dependencies` und werden deshalb trotz `--omit=dev` geprüft. **Eine Erreichbarkeit der gefährdeten Funktionen durch anonyme Web-Anfragen wurde nicht nachgewiesen.** Die Paket-Schweregrade dürfen nicht automatisch als bestätigte kritische Loginlücke interpretiert werden.
|
||||
|
||||
Empfehlung: die betroffenen Abhängigkeitsketten gezielt aktualisieren, danach Build und Audit erneut prüfen. Entsprechend dem reinen Audit-Auftrag wurde weder `npm audit fix` noch eine andere Paketänderung ausgeführt.
|
||||
|
||||
### Offen geblieben
|
||||
|
||||
- Dynamische Bestätigung der sechs Befunde in einer isolierten Testumgebung.
|
||||
- Produktionswerte, tatsächliche Rollen-/Permission-Zuordnung, Altbestand und Zahl betroffener Konten/Rechnungen.
|
||||
- TLS, Reverse-Proxy-Header, Firewall, Datenbank-/Redis-Erreichbarkeit, Betriebssystem und Backups.
|
||||
- Vollständige Prüfung der tatsächlichen produktiven Erreichbarkeit der npm-Funde; separat als `devDependencies` deklarierte npm-Pakete waren durch `--omit=dev` nicht Teil dieses Audits.
|
||||
- Vollständiger Penetrationstest aller Endpunkte und aller möglichen Race Conditions. Die Stichproben zu Uploads, HTML und API begründen keine pauschale Fehlerfreiheit dieser Bereiche.
|
||||
|
||||
## 7. Empfohlene Reihenfolge
|
||||
|
||||
1. **S-01 und S-02 schließen:** konkrete Rollenüberschreitungen an Download und Schreibaktion.
|
||||
2. **S-03 und S-04 schließen:** Rechteentzug und Kontowiederherstellung müssen auch bei bereits bestehenden Sitzungen greifen.
|
||||
3. **S-05 schließen:** Google-Identität und Verknüpfung vorhandener Konten verbindlich prüfen.
|
||||
4. **S-06 schließen:** frische Identitätsnachweise vor dauerhaften Sicherheitsänderungen.
|
||||
5. Die gezielten Negativtests ergänzen, die gefundenen npm-Abhängigkeiten aktualisieren und die produktiven Proxy-/Cookie-/TLS-Einstellungen verifizieren.
|
||||
|
||||
**Änderungsstatus:** Audit-Dokumentation und ausdrücklich angeforderte Dev-Container-Projektnotiz erstellt/aktualisiert; keine Sicherheitskorrekturen umgesetzt.
|
||||
|
||||
## 8. Nachtrag – Umsetzung vom 07.10.2026
|
||||
|
||||
S-01 bis S-06 sind im Code behoben und durch Regressionstests abgesichert. Die Entscheidungen, Details, Testdateien und Deploy-Schritte stehen in [ENTWICKLUNGSPLAN-SICHERHEIT-2026-10-07.md](ENTWICKLUNGSPLAN-SICHERHEIT-2026-10-07.md), Abschnitt 0.
|
||||
|
||||
Nicht umgesetzt wurden die Härtungspunkte aus Abschnitt 4, darunter Pflicht-2FA für Admins, npm-Updates, Proxy, HSTS und CSP.
|
||||
35
package-lock.json
generated
35
package-lock.json
generated
|
|
@ -8,6 +8,7 @@
|
|||
"@fontsource/instrument-sans": "^5.0.0",
|
||||
"@fontsource/inter": "^5.0.0",
|
||||
"@fontsource/poppins": "^5.0.0",
|
||||
"@laravel/passkeys": "^0.4.0",
|
||||
"@sentry/browser": "^11.3.0",
|
||||
"@tailwindcss/vite": "^4.0.7",
|
||||
"alpinejs": "^3.14.9",
|
||||
|
|
@ -522,6 +523,34 @@
|
|||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@laravel/passkeys": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@laravel/passkeys/-/passkeys-0.4.0.tgz",
|
||||
"integrity": "sha512-59UHJHZPrIT8f5hFQJUR1EXhazWZTg3DcJefxSfA3aRj6xu40I/lYDveUY4oL2/h/crq9jjptSKykZDDold7aA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@simplewebauthn/browser": "^13.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"svelte": "^5.0.0",
|
||||
"vue": "^3.3.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"svelte": {
|
||||
"optional": true
|
||||
},
|
||||
"vue": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/lzma-linux-x64-gnu": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
|
||||
|
|
@ -995,6 +1024,12 @@
|
|||
"node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@simplewebauthn/browser": {
|
||||
"version": "13.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.3.0.tgz",
|
||||
"integrity": "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tailwindcss/node": {
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz",
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
"@fontsource/instrument-sans": "^5.0.0",
|
||||
"@fontsource/inter": "^5.0.0",
|
||||
"@fontsource/poppins": "^5.0.0",
|
||||
"@laravel/passkeys": "^0.4.0",
|
||||
"@sentry/browser": "^11.3.0",
|
||||
"@tailwindcss/vite": "^4.0.7",
|
||||
"alpinejs": "^3.14.9",
|
||||
|
|
|
|||
|
|
@ -456,6 +456,12 @@ parameters:
|
|||
count: 1
|
||||
path: app/Listeners/RecordMailActivity.php
|
||||
|
||||
-
|
||||
message: '#^Trait App\\Livewire\\Concerns\\ConfirmsIdentity is used zero times and is not analysed\.$#'
|
||||
identifier: trait.unused
|
||||
count: 1
|
||||
path: app/Livewire/Concerns/ConfirmsIdentity.php
|
||||
|
||||
-
|
||||
message: '#^Trait App\\Livewire\\Concerns\\HandlesAdminPressReleaseActions is used zero times and is not analysed\.$#'
|
||||
identifier: trait.unused
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@
|
|||
<env name="SESSION_DRIVER" value="array"/>
|
||||
<env name="TELESCOPE_ENABLED" value="false"/>
|
||||
<env name="BASIC_AUTH_ENABLED" value="false"/>
|
||||
<env name="AUTH_REQUIRE_STRONG_ADMIN" value="false" force="true"/>
|
||||
<env name="BILLING_ENFORCE_BOOKING" value="false" force="true"/>
|
||||
<!-- Bildkennzeichnung/Rechtstexte: die Suite läuft mit den Vorgaben aus .env.example; Tests, die ein Flag brauchen, setzen es per config()->set (Umsetzungsaudit 25.09.2026, Abschnitt 6). -->
|
||||
<env name="IMAGE_RIGHTS_V2" value="false" force="true"/>
|
||||
|
|
|
|||
48
resources/js/passkeys.js
Normal file
48
resources/js/passkeys.js
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
// Passkeys (Entscheidung 5, 07.10.2026): dünne Hülle um den offiziellen
|
||||
// Laravel-Client für Login-Seite, Sicherheitsseite und Admin-Bestätigung.
|
||||
// Kein eigenes Alpine — die Seiten rufen window.PkPasskeys aus ihren
|
||||
// bestehenden Alpine-Komponenten auf. Ergebnis immer { ok, error, result }.
|
||||
import {
|
||||
Passkeys,
|
||||
InvalidDomainError,
|
||||
NotSupportedError,
|
||||
PasskeyExistsError,
|
||||
UserCancelledError,
|
||||
} from "@laravel/passkeys";
|
||||
|
||||
const CONFIRM_ROUTES = {
|
||||
options: "/passkeys/confirm/options",
|
||||
submit: "/passkeys/confirm",
|
||||
};
|
||||
|
||||
function messageFor(error) {
|
||||
if (error instanceof UserCancelledError) {
|
||||
return "Der Vorgang wurde abgebrochen.";
|
||||
}
|
||||
if (error instanceof NotSupportedError) {
|
||||
return "Dieser Browser unterstützt keine Passkeys.";
|
||||
}
|
||||
if (error instanceof PasskeyExistsError) {
|
||||
return "Für dieses Gerät ist bereits ein Passkey hinterlegt.";
|
||||
}
|
||||
if (error instanceof InvalidDomainError) {
|
||||
return "Passkeys funktionieren nur auf der Anmeldeseite von pressekonto.";
|
||||
}
|
||||
|
||||
return "Der Passkey konnte nicht geprüft werden. Bitte versuchen Sie es erneut.";
|
||||
}
|
||||
|
||||
async function run(action) {
|
||||
try {
|
||||
return { ok: true, error: null, result: await action() };
|
||||
} catch (error) {
|
||||
return { ok: false, error: messageFor(error), result: null };
|
||||
}
|
||||
}
|
||||
|
||||
window.PkPasskeys = {
|
||||
isSupported: () => Passkeys.isSupported(),
|
||||
login: (remember = false) => run(() => Passkeys.verify({ remember })),
|
||||
confirm: () => run(() => Passkeys.verify({ routes: CONFIRM_ROUTES })),
|
||||
register: (name) => run(() => Passkeys.register({ name })),
|
||||
};
|
||||
|
|
@ -450,7 +450,7 @@
|
|||
<flux:toast position="top end" class="pt-24" />
|
||||
@endpersist
|
||||
|
||||
@vite(['resources/js/portal-form-hooks.js'], 'build/portal')
|
||||
@vite(['resources/js/portal-form-hooks.js', 'resources/js/passkeys.js'], 'build/portal')
|
||||
@fluxScripts
|
||||
</body>
|
||||
|
||||
|
|
|
|||
|
|
@ -58,6 +58,8 @@
|
|||
laden, gäbe es zwei Alpine-Instanzen und wire:submit, x-data,
|
||||
wire:model würden brechen (siehe Browser-Logs vor diesem Fix). --}}
|
||||
@vite([$themeCssPath], $assetsDir)
|
||||
{{-- Passkey-Login (Entscheidung 5): kleiner Client ohne eigenes Alpine. --}}
|
||||
@vite(['resources/js/passkeys.js'], 'build/portal')
|
||||
@livewireStyles
|
||||
<x-monitoring.browser-errors :build="$assetsDir" area="auth" />
|
||||
</head>
|
||||
|
|
|
|||
23
resources/views/emails/auth/security-change-notice.blade.php
Normal file
23
resources/views/emails/auth/security-change-notice.blade.php
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<x-emails.layout title="Sicherheitshinweis" preheader="An Ihrem pressekonto wurde eine sicherheitsrelevante Einstellung geändert.">
|
||||
<p>Hallo {{ $user->name }},</p>
|
||||
|
||||
<p><strong>{{ $change->label() }}</strong></p>
|
||||
|
||||
@if ($newEmail)
|
||||
<p>Neue Login-Adresse: <strong>{{ $newEmail }}</strong>. Diese Nachricht geht an Ihre bisherige Adresse; künftige Kontomails erhalten Sie an die neue.</p>
|
||||
@endif
|
||||
|
||||
<p>Zeitpunkt: {{ $occurredAt }} Uhr.</p>
|
||||
|
||||
@if ($change === \App\Enums\SecurityChange::SecondFactorsReset)
|
||||
<p>Alle angemeldeten Geräte wurden abgemeldet. Bitte melden Sie sich erneut an und richten Sie unter „Sicherheit“ einen neuen Passkey oder die Authenticator-App ein.</p>
|
||||
@endif
|
||||
|
||||
@if ($change->endsOtherSessions())
|
||||
<p>Alle anderen angemeldeten Geräte wurden dabei abgemeldet. Bestehende API-Tokens und Passkeys bleiben gültig — prüfen Sie diese bei Bedarf in Ihrem Konto unter „Sicherheit“ bzw. „API-Tokens“.</p>
|
||||
@endif
|
||||
|
||||
<div class="note">
|
||||
<p>Waren Sie das nicht? Setzen Sie Ihr Passwort über „Passwort vergessen“ auf der Login-Seite zurück und wenden Sie sich an {{ config('legal.contact_email') }}.</p>
|
||||
</div>
|
||||
</x-emails.layout>
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
<x-emails.layout title="Ihr Bestätigungscode" preheader="Bestätigungscode für eine Sicherheitsänderung an Ihrem pressekonto.">
|
||||
<p>Hallo {{ $user->name }},</p>
|
||||
|
||||
<p>für eine Sicherheitsänderung an Ihrem Konto (z. B. neue Login-Adresse, Zwei-Faktor-Authentifizierung oder API-Token) benötigen wir eine Bestätigung. Ihr Code lautet:</p>
|
||||
|
||||
<p style="font-size: 26px; font-weight: 700; letter-spacing: 6px; text-align: center;">{{ $code }}</p>
|
||||
|
||||
<p>Der Code ist gültig bis {{ $expiresAt }} Uhr und kann nur einmal verwendet werden.</p>
|
||||
|
||||
<div class="note">
|
||||
<p>Falls Sie keine Änderung vorgenommen haben, geben Sie den Code nicht weiter und setzen Sie Ihr Passwort über „Passwort vergessen“ zurück — jemand nutzt möglicherweise eine Anmeldung zu Ihrem Konto.</p>
|
||||
</div>
|
||||
</x-emails.layout>
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
<x-emails.layout title="Google-Anmeldung bestätigen" preheader="Bestätigen Sie die Verknüpfung Ihres Google-Kontos mit Ihrem pressekonto.">
|
||||
<p>Hallo {{ $user->name }},</p>
|
||||
|
||||
<p>soeben wurde versucht, sich mit einem Google-Konto bei Ihrem pressekonto anzumelden. Damit diese Anmeldung künftig möglich ist, bestätigen Sie bitte die Verknüpfung.</p>
|
||||
|
||||
<x-emails.button :url="$confirmUrl">Google-Anmeldung bestätigen</x-emails.button>
|
||||
|
||||
<p>Der Link ist gültig bis {{ $expiresAt }} Uhr und funktioniert nur in dem Browser, in dem Sie die Anmeldung mit Google gestartet haben.</p>
|
||||
|
||||
<div class="note">
|
||||
<p>Falls Sie das nicht waren, ignorieren Sie diese E-Mail bitte — ohne Bestätigung wird nichts verknüpft.</p>
|
||||
</div>
|
||||
</x-emails.layout>
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
use App\Models\Company;
|
||||
use App\Models\Contact;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Component;
|
||||
|
||||
|
|
@ -10,6 +11,7 @@ new #[Layout('components.layouts.app'), Title('Firma anzeigen')] class extends C
|
|||
{
|
||||
use \App\Livewire\Concerns\HasFullTextSearch;
|
||||
|
||||
#[Locked]
|
||||
public int $id;
|
||||
|
||||
public string $activeTab = 'overview';
|
||||
|
|
@ -53,8 +55,16 @@ new #[Layout('components.layouts.app'), Title('Firma anzeigen')] class extends C
|
|||
$this->selectedExistingContactId = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hängt einen bestehenden Kontakt an diese Firma um. Die Seite ist für
|
||||
* Editoren lesbar, die Aktion schreibt aber — daher `companies:write`
|
||||
* direkt an der Mutation (Sicherheitsaudit 07.10.2026, S-02). Greift auch
|
||||
* für den Aufruf über updatedSelectedExistingContactId().
|
||||
*/
|
||||
public function attachExistingContact(): void
|
||||
{
|
||||
$this->authorize('companies:write');
|
||||
|
||||
if (! $this->selectedExistingContactId) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -146,7 +156,7 @@ new #[Layout('components.layouts.app'), Title('Firma anzeigen')] class extends C
|
|||
$contactLookupResults = collect();
|
||||
$lookupTerm = trim($this->contactLookup);
|
||||
|
||||
if ($this->activeTab === 'contacts' && mb_strlen($lookupTerm) >= 1) {
|
||||
if ($this->activeTab === 'contacts' && mb_strlen($lookupTerm) >= 1 && auth()->user()->can('companies:write')) {
|
||||
$contactLookupResults = Contact::withoutGlobalScopes()
|
||||
->with('company:id,name')
|
||||
->where('company_id', '!=', $company->id)
|
||||
|
|
@ -398,13 +408,14 @@ new #[Layout('components.layouts.app'), Title('Firma anzeigen')] class extends C
|
|||
icon="magnifying-glass"
|
||||
class="flex-1"
|
||||
/>
|
||||
@if (\Illuminate\Support\Facades\Route::has('admin.companies.contacts.create'))
|
||||
@can('companies:write')
|
||||
<flux:button size="sm" icon="plus" href="{{ route('admin.companies.contacts.create', ['companyId' => $company->id]) }}" wire:navigate>
|
||||
{{ __('Neu') }}
|
||||
</flux:button>
|
||||
@endif
|
||||
@endcan
|
||||
</div>
|
||||
|
||||
@can('companies:write')
|
||||
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-4">
|
||||
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold mb-2">
|
||||
{{ __('Bestehenden Kontakt zuordnen') }}
|
||||
|
|
@ -444,6 +455,7 @@ new #[Layout('components.layouts.app'), Title('Firma anzeigen')] class extends C
|
|||
</x-slot>
|
||||
</flux:select>
|
||||
</div>
|
||||
@endcan
|
||||
|
||||
<div class="space-y-2">
|
||||
@forelse ($filteredContacts as $contact)
|
||||
|
|
|
|||
|
|
@ -2,10 +2,13 @@
|
|||
|
||||
use App\Enums\Portal;
|
||||
use App\Enums\RegistrationType;
|
||||
use App\Enums\SecurityChange;
|
||||
use App\Models\Company;
|
||||
use App\Models\Contact;
|
||||
use App\Models\User;
|
||||
use App\Services\Admin\AdminPerformanceCache;
|
||||
use App\Services\Auth\SecurityNotifier;
|
||||
use App\Services\Auth\SessionRevocation;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Livewire\Attributes\Layout;
|
||||
|
|
@ -622,6 +625,48 @@ new #[Layout('components.layouts.app'), Title('Benutzer bearbeiten')] class exte
|
|||
'countries' => config('countries.items', []),
|
||||
'portalOptions' => Portal::cases(),
|
||||
'registrationTypeOptions' => RegistrationType::cases(),
|
||||
'secondFactors' => $this->secondFactorsOfTarget(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Notfallweg bei verlorenem Gerät (Entscheidung 5, 07.10.2026): Ein
|
||||
* Super-Admin setzt Passkeys und Authenticator-App eines anderen Kontos
|
||||
* zurück. Alle Sessions des Kontos enden, der Inhaber wird informiert und
|
||||
* richtet den zweiten Faktor nach der nächsten Anmeldung neu ein.
|
||||
*/
|
||||
public function resetSecondFactors(SessionRevocation $sessions, SecurityNotifier $notifier): void
|
||||
{
|
||||
$this->authorize('users:manage');
|
||||
abort_unless(auth()->user()->is_super_admin, 403);
|
||||
|
||||
$user = User::query()->findOrFail($this->id);
|
||||
abort_if($user->is(auth()->user()), 403, __('Das eigene Konto verwalten Sie unter „Sicherheit“.'));
|
||||
|
||||
$user->passkeys()->delete();
|
||||
$user->forceFill([
|
||||
'two_factor_secret' => null,
|
||||
'two_factor_recovery_codes' => null,
|
||||
'two_factor_confirmed_at' => null,
|
||||
])->save();
|
||||
|
||||
$sessions->revokeAllFor($user);
|
||||
$notifier->notify($user, SecurityChange::SecondFactorsReset);
|
||||
|
||||
session()->flash('success', __('Passkeys und Zwei-Faktor-Authentifizierung wurden zurückgesetzt. Alle Sitzungen des Kontos sind beendet.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{passkeys: int, totp: bool, canReset: bool}
|
||||
*/
|
||||
private function secondFactorsOfTarget(): array
|
||||
{
|
||||
$user = User::query()->find($this->id);
|
||||
|
||||
return [
|
||||
'passkeys' => $user?->passkeys()->count() ?? 0,
|
||||
'totp' => (bool) $user?->hasEnabledTwoFactorAuthentication(),
|
||||
'canReset' => $user !== null && auth()->user()->is_super_admin && ! $user->is(auth()->user()),
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -1160,6 +1205,24 @@ new #[Layout('components.layouts.app'), Title('Benutzer bearbeiten')] class exte
|
|||
@endforelse
|
||||
</flux:card>
|
||||
|
||||
{{-- Zweiter Faktor (Entscheidung 5): Notfall-Reset nur durch Super-Admins. --}}
|
||||
<flux:card id="second-factors">
|
||||
<flux:heading size="lg" class="mb-2">{{ __('Zweiter Faktor') }}</flux:heading>
|
||||
<p class="text-[13px] text-[color:var(--color-ink-2)] m-0">
|
||||
{{ trans_choice('{0} Keine Passkeys|{1} :count Passkey|[2,*] :count Passkeys', $secondFactors['passkeys'], ['count' => $secondFactors['passkeys']]) }}
|
||||
· {{ $secondFactors['totp'] ? __('Authenticator-App aktiv') : __('keine Authenticator-App') }}
|
||||
</p>
|
||||
@if ($secondFactors['canReset'] && ($secondFactors['passkeys'] > 0 || $secondFactors['totp']))
|
||||
<div class="mt-4">
|
||||
<flux:button type="button" variant="danger" icon="arrow-path"
|
||||
wire:click="resetSecondFactors"
|
||||
wire:confirm="{{ __('Passkeys und Authenticator-App dieses Kontos zurücksetzen und alle Sitzungen beenden? Nur bei verlorenem Gerät und nach Rücksprache mit dem Inhaber.') }}">
|
||||
{{ __('Zweiten Faktor zurücksetzen') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
@endif
|
||||
</flux:card>
|
||||
|
||||
<flux:card id="billing-address">
|
||||
<flux:heading size="lg" class="mb-4">{{ __('Rechnungsadresse') }}</flux:heading>
|
||||
|
||||
|
|
|
|||
|
|
@ -175,6 +175,27 @@ new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Willkommen zu
|
|||
<span class="flex-1 h-px bg-bg-rule"></span>
|
||||
</div>
|
||||
|
||||
{{-- Passkey-Login (Entscheidung 5, 07.10.2026): Fingerabdruck, Gesicht
|
||||
oder Geräte-PIN statt Passwort und App-Code. Nur sichtbar, wenn der
|
||||
Browser WebAuthn kann. --}}
|
||||
<div x-data="{ busy: false, error: null, supported: typeof window.PublicKeyCredential !== 'undefined' }" x-show="supported" class="!mt-0 mb-3">
|
||||
<button type="button" class="auth-btn-outline !mt-0" x-bind:disabled="busy"
|
||||
x-on:click="busy = true; error = null;
|
||||
const outcome = await window.PkPasskeys.login($wire.remember);
|
||||
if (outcome.ok) { window.location.href = outcome.result?.redirect ?? '/'; return; }
|
||||
error = outcome.error; busy = false;">
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<circle cx="6" cy="5" r="3" stroke="currentColor" stroke-width="1.4" />
|
||||
<path d="M1.5 14c0-2.5 2-4.5 4.5-4.5 1 0 1.9.3 2.6.8" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" />
|
||||
<circle cx="12" cy="10" r="2" stroke="currentColor" stroke-width="1.4" />
|
||||
<path d="M12 12v3M12 14h1.5" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" />
|
||||
</svg>
|
||||
<span x-show="! busy">{{ __('Mit Passkey anmelden') }}</span>
|
||||
<span x-show="busy">{{ __('Passkey wird geprüft …') }}</span>
|
||||
</button>
|
||||
<p class="field-error" x-show="error" x-text="error"></p>
|
||||
</div>
|
||||
|
||||
<a href="{{ route('magic-link.request') }}" class="auth-btn-outline !mt-0" wire:navigate>
|
||||
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<rect x="2" y="3" width="12" height="10" stroke="currentColor" stroke-width="1.4" />
|
||||
|
|
|
|||
|
|
@ -59,7 +59,9 @@ new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Neues Passwor
|
|||
return;
|
||||
}
|
||||
|
||||
Session::flash('status', __($status));
|
||||
// Alle offenen Sessions sind beendet (RevokeAccessAfterPasswordReset);
|
||||
// API-Tokens bleiben bewusst gültig (Sicherheitsaudit 07.10.2026, S-04).
|
||||
Session::flash('status', __('Ihr Passwort wurde zurückgesetzt. Alle angemeldeten Geräte wurden abgemeldet. Bitte prüfen Sie nach der Anmeldung unter „Sicherheit“ auch Ihre API-Tokens.'));
|
||||
|
||||
$this->redirectRoute('login', navigate: true);
|
||||
}
|
||||
|
|
|
|||
147
resources/views/livewire/auth/strong-confirmation.blade.php
Normal file
147
resources/views/livewire/auth/strong-confirmation.blade.php
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Concerns\ConfirmsIdentity;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\SecurityConfirmation;
|
||||
use App\Services\Auth\StrongAuthentication;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Component;
|
||||
|
||||
/**
|
||||
* Einmalige Bestätigung der Session für den Admin-Bereich (Entscheidung 5,
|
||||
* 07.10.2026): per Passkey (Browser-Zeremonie gegen /passkeys/confirm, die
|
||||
* Marke setzt TrackSecondFactorEvents) oder per Authenticator-Code. Ohne
|
||||
* beides führt die Seite zur Einrichtung auf der Sicherheitsseite.
|
||||
*/
|
||||
new #[Layout('components.layouts.app'), Title('Anmeldung bestätigen')] class extends Component
|
||||
{
|
||||
use ConfirmsIdentity;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
if (app(StrongAuthentication::class)->isSatisfiedBy($this->user())) {
|
||||
$this->continueToIntended();
|
||||
}
|
||||
}
|
||||
|
||||
public function confirmWithCode(): void
|
||||
{
|
||||
$user = $this->user();
|
||||
|
||||
abort_unless($user->hasEnabledTwoFactorAuthentication(), 403);
|
||||
|
||||
$this->assertSecondFactor($user);
|
||||
|
||||
app(StrongAuthentication::class)->mark($user, 'totp');
|
||||
app(SecurityConfirmation::class)->markConfirmed($user);
|
||||
|
||||
$this->continueToIntended();
|
||||
}
|
||||
|
||||
/**
|
||||
* Aufruf nach erfolgreicher Passkey-Zeremonie im Browser.
|
||||
*/
|
||||
public function passkeyConfirmed(): void
|
||||
{
|
||||
if (! app(StrongAuthentication::class)->isSatisfiedBy($this->user())) {
|
||||
$this->addError('passkey', __('Der Passkey konnte nicht bestätigt werden. Bitte versuchen Sie es erneut.'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->continueToIntended();
|
||||
}
|
||||
|
||||
public function with(): array
|
||||
{
|
||||
$user = $this->user();
|
||||
|
||||
return [
|
||||
'hasPasskeys' => app(StrongAuthentication::class)->hasPasskeys($user),
|
||||
'hasTotp' => $user->hasEnabledTwoFactorAuthentication(),
|
||||
];
|
||||
}
|
||||
|
||||
private function continueToIntended(): void
|
||||
{
|
||||
$intended = session()->pull(StrongAuthentication::INTENDED_KEY);
|
||||
$host = request()->getHost();
|
||||
|
||||
// Nur Ziele auf dieser Domain (kein offener Redirect).
|
||||
$target = is_string($intended) && parse_url($intended, PHP_URL_HOST) === $host
|
||||
? $intended
|
||||
: route('dashboard');
|
||||
|
||||
$this->redirect($target);
|
||||
}
|
||||
|
||||
private function user(): User
|
||||
{
|
||||
/** @var User */
|
||||
return auth()->user();
|
||||
}
|
||||
}; ?>
|
||||
|
||||
<div class="max-w-[560px] space-y-6">
|
||||
<header class="page-header">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<span class="eyebrow muted">{{ __('Admin-Bereich') }}</span>
|
||||
</div>
|
||||
<h1 class="text-[26px] font-bold tracking-[-0.5px] leading-[1.15] m-0 text-[color:var(--color-ink)]">
|
||||
{{ __('Anmeldung bestätigen') }}
|
||||
</h1>
|
||||
<p class="text-[13px] leading-[1.55] mt-2 m-0 text-[color:var(--color-ink-2)]">
|
||||
{{ __('Für den Admin-Bereich bestätigen Sie Ihre Anmeldung einmalig mit einem zweiten Faktor. Die Bestätigung gilt, bis Sie sich abmelden.') }}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if ($hasPasskeys)
|
||||
<article class="panel p-5 space-y-3"
|
||||
x-data="{ busy: false, error: null }">
|
||||
<div class="section-eyebrow">{{ __('Mit Passkey') }}</div>
|
||||
<p class="text-[13px] text-[color:var(--color-ink-2)] m-0">
|
||||
{{ __('Fingerabdruck, Gesicht oder Geräte-PIN — ohne Code aus der App.') }}
|
||||
</p>
|
||||
<flux:button variant="primary" icon="finger-print" x-bind:disabled="busy"
|
||||
x-on:click="busy = true; error = null;
|
||||
const outcome = await window.PkPasskeys.confirm();
|
||||
if (outcome.ok) { await $wire.passkeyConfirmed(); }
|
||||
else { error = outcome.error; }
|
||||
busy = false;">
|
||||
{{ __('Mit Passkey bestätigen') }}
|
||||
</flux:button>
|
||||
<p class="text-[12px] text-[color:var(--color-warn,#b91c1c)] m-0" x-show="error" x-text="error"></p>
|
||||
@error('passkey')
|
||||
<p class="text-[12px] text-[color:var(--color-warn,#b91c1c)] m-0">{{ $message }}</p>
|
||||
@enderror
|
||||
</article>
|
||||
@endif
|
||||
|
||||
@if ($hasTotp)
|
||||
<article class="panel p-5">
|
||||
<form wire:submit="confirmWithCode" class="space-y-3 max-w-[320px]">
|
||||
<div class="section-eyebrow">{{ __('Mit Authenticator-App') }}</div>
|
||||
<flux:input wire:model="securityCode" :label="__('Code aus der App oder Wiederherstellungs-Code')"
|
||||
inputmode="numeric" autocomplete="one-time-code" placeholder="123456" required />
|
||||
<flux:button type="submit" :variant="$hasPasskeys ? 'filled' : 'primary'">
|
||||
{{ __('Bestätigen') }}
|
||||
</flux:button>
|
||||
</form>
|
||||
</article>
|
||||
@endif
|
||||
|
||||
@if (! $hasPasskeys && ! $hasTotp)
|
||||
<article class="panel p-5 space-y-3">
|
||||
<div class="section-eyebrow">{{ __('Zweiten Faktor einrichten') }}</div>
|
||||
<p class="text-[13px] text-[color:var(--color-ink-2)] m-0">
|
||||
{{ __('Für Administratoren ist ein zweiter Faktor Pflicht. Richten Sie am einfachsten einen Passkey ein — alternativ die Authenticator-App. Danach geht es hier direkt weiter.') }}
|
||||
</p>
|
||||
<flux:button variant="primary" icon="shield-check" :href="route('me.security')" wire:navigate>
|
||||
{{ __('Jetzt einrichten') }}
|
||||
</flux:button>
|
||||
</article>
|
||||
@endif
|
||||
</div>
|
||||
|
|
@ -1,12 +1,16 @@
|
|||
<?php
|
||||
|
||||
use App\Actions\Admin\UserImpersonation;
|
||||
use App\Enums\SecurityChange;
|
||||
use App\Livewire\Concerns\ConfirmsIdentity;
|
||||
use App\Mail\EmailChangeConfirmation;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\PasswordChanger;
|
||||
use App\Services\Auth\SecurityNotifier;
|
||||
use App\Services\Auth\StrongAuthentication;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
|
@ -17,7 +21,7 @@ use Laravel\Fortify\Actions\DisableTwoFactorAuthentication;
|
|||
use Laravel\Fortify\Actions\EnableTwoFactorAuthentication;
|
||||
use Laravel\Fortify\Actions\GenerateNewRecoveryCodes;
|
||||
use Laravel\Fortify\Features;
|
||||
use Laravel\Fortify\TwoFactorAuthenticationProvider;
|
||||
use Laravel\Passkeys\Actions\DeletePasskey;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Attributes\Title;
|
||||
|
|
@ -25,6 +29,8 @@ use Livewire\Component;
|
|||
|
||||
new #[Layout('components.layouts.app'), Title('Konto-Sicherheit')] class extends Component
|
||||
{
|
||||
use ConfirmsIdentity;
|
||||
|
||||
public string $current_password = '';
|
||||
|
||||
public string $password = '';
|
||||
|
|
@ -35,15 +41,7 @@ new #[Layout('components.layouts.app'), Title('Konto-Sicherheit')] class extends
|
|||
|
||||
public string $twoFactorCode = '';
|
||||
|
||||
/**
|
||||
* Erneuter Nachweis vor sicherheitskritischen Aktionen (Audit F03, E3):
|
||||
* bei aktiver 2FA ein aktueller App-Code oder ein Wiederherstellungs-Code,
|
||||
* sonst das Passwort. Eine bestehende Session allein reicht nicht, um die
|
||||
* 2FA abzuschalten, Codes neu zu erzeugen oder die Adresse zu wechseln.
|
||||
*/
|
||||
public string $securityCode = '';
|
||||
|
||||
public string $securityPassword = '';
|
||||
public string $passkeyName = '';
|
||||
|
||||
/**
|
||||
* Recovery-Codes werden nur direkt nach Bestätigung bzw. Neu-Erzeugung
|
||||
|
|
@ -72,13 +70,11 @@ new #[Layout('components.layouts.app'), Title('Konto-Sicherheit')] class extends
|
|||
throw $e;
|
||||
}
|
||||
|
||||
Auth::user()->forceFill([
|
||||
'password' => Hash::make($validated['password']),
|
||||
])->save();
|
||||
app(PasswordChanger::class)->change(Auth::user(), $validated['password'], session()->driver());
|
||||
|
||||
$this->reset('current_password', 'password', 'password_confirmation');
|
||||
|
||||
session()->flash('security-status', __('Passwort aktualisiert.'));
|
||||
session()->flash('security-status', __('Passwort aktualisiert. Alle anderen angemeldeten Geräte wurden abgemeldet.'));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -135,9 +131,19 @@ new #[Layout('components.layouts.app'), Title('Konto-Sicherheit')] class extends
|
|||
session()->flash('security-status', __('Die ausstehende E-Mail-Änderung wurde verworfen.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstmalige Einrichtung nur mit frischem Nachweis (Sicherheitsaudit
|
||||
* 07.10.2026, S-06): Wer nur eine fremde Session hat, soll keinen eigenen
|
||||
* Authenticator registrieren und den Inhaber damit aussperren können.
|
||||
*/
|
||||
public function enableTwoFactorAuthentication(EnableTwoFactorAuthentication $enable): void
|
||||
{
|
||||
$enable(auth()->user());
|
||||
/** @var User $user */
|
||||
$user = auth()->user();
|
||||
|
||||
$this->assertSecurityProof($user);
|
||||
|
||||
$enable($user);
|
||||
$this->showRecoveryCodes = false;
|
||||
|
||||
session()->flash('security-status', __('Scannen Sie den QR-Code mit Ihrer Authenticator-App und bestätigen Sie die Einrichtung mit einem aktuellen Code.'));
|
||||
|
|
@ -162,6 +168,12 @@ new #[Layout('components.layouts.app'), Title('Konto-Sicherheit')] class extends
|
|||
$this->reset('twoFactorCode');
|
||||
$this->showRecoveryCodes = true;
|
||||
|
||||
app(SecurityNotifier::class)->notify(auth()->user(), SecurityChange::TwoFactorEnabled);
|
||||
|
||||
// Der gerade bestätigte Code belegt den zweiten Faktor auch für den
|
||||
// Admin-Bereich (Entscheidung 5).
|
||||
app(StrongAuthentication::class)->mark(auth()->user(), 'totp');
|
||||
|
||||
session()->flash('security-status', __('Zwei-Faktor-Authentifizierung ist jetzt aktiv. Speichern Sie Ihre Wiederherstellungs-Codes — sie werden nur einmal angezeigt.'));
|
||||
}
|
||||
|
||||
|
|
@ -172,7 +184,9 @@ new #[Layout('components.layouts.app'), Title('Konto-Sicherheit')] class extends
|
|||
|
||||
// Eine noch unbestätigte Einrichtung abzubrechen braucht keinen
|
||||
// Nachweis – aktiv ist die 2FA erst nach dem bestätigten App-Code.
|
||||
if ($user->hasEnabledTwoFactorAuthentication()) {
|
||||
$wasEnabled = $user->hasEnabledTwoFactorAuthentication();
|
||||
|
||||
if ($wasEnabled) {
|
||||
$this->assertSecondFactor($user);
|
||||
}
|
||||
|
||||
|
|
@ -180,6 +194,10 @@ new #[Layout('components.layouts.app'), Title('Konto-Sicherheit')] class extends
|
|||
$this->reset('twoFactorCode');
|
||||
$this->showRecoveryCodes = false;
|
||||
|
||||
if ($wasEnabled) {
|
||||
app(SecurityNotifier::class)->notify($user, SecurityChange::TwoFactorDisabled);
|
||||
}
|
||||
|
||||
session()->flash('security-status', __('Zwei-Faktor-Authentifizierung deaktiviert.'));
|
||||
}
|
||||
|
||||
|
|
@ -197,70 +215,49 @@ new #[Layout('components.layouts.app'), Title('Konto-Sicherheit')] class extends
|
|||
}
|
||||
|
||||
/**
|
||||
* Nachweis je nach Kontoart: 2FA aktiv → zweiter Faktor; Passwort gesetzt
|
||||
* → Passwort; reines Magic-Link-Konto ohne beides → kein weiterer Nachweis
|
||||
* (der Zugang selbst ist dann bereits der Postfach-Nachweis, und die neue
|
||||
* Adresse muss ihren Bestätigungslink ohnehin einlösen).
|
||||
* Startet das Hinzufügen eines Passkeys (Entscheidung 5, 07.10.2026):
|
||||
* erst der frische Identitätsnachweis, dann die Browser-Zeremonie gegen
|
||||
* Fortifys Registrierungsrouten (EnsurePasskeyManagementIsConfirmed prüft
|
||||
* den Nachweis dort erneut).
|
||||
*/
|
||||
private function assertSecurityProof(User $user): void
|
||||
public function startPasskeyRegistration(): void
|
||||
{
|
||||
if ($user->hasEnabledTwoFactorAuthentication()) {
|
||||
$this->assertSecondFactor($user);
|
||||
$this->ensureNotImpersonating();
|
||||
|
||||
return;
|
||||
}
|
||||
$validated = $this->validate(['passkeyName' => ['required', 'string', 'max:60']], [], [
|
||||
'passkeyName' => __('Name des Passkeys'),
|
||||
]);
|
||||
|
||||
if ((string) $user->password === '') {
|
||||
return;
|
||||
}
|
||||
$this->assertSecurityProof(auth()->user());
|
||||
|
||||
try {
|
||||
$this->validate(['securityPassword' => ['required', 'string', 'current_password']]);
|
||||
} finally {
|
||||
$this->reset('securityPassword');
|
||||
}
|
||||
$this->dispatch('passkey-register', name: $validated['passkeyName']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft `securityCode` als TOTP (Fortify-Provider, replay-geschützt) oder
|
||||
* als Wiederherstellungs-Code (wird dabei verbraucht). Fünf Fehlversuche
|
||||
* pro Minute, damit eine Session die sechs Stellen nicht durchprobiert.
|
||||
*/
|
||||
private function assertSecondFactor(User $user): void
|
||||
public function passkeyRegistered(): void
|
||||
{
|
||||
$this->validate(['securityCode' => ['required', 'string']]);
|
||||
$this->reset('passkeyName');
|
||||
|
||||
$code = trim($this->securityCode);
|
||||
$this->reset('securityCode');
|
||||
session()->flash('security-status', __('Passkey hinzugefügt. Sie können sich ab sofort damit anmelden.'));
|
||||
}
|
||||
|
||||
$throttleKey = 'security-proof:'.$user->getKey();
|
||||
public function deletePasskey(int $passkeyId, DeletePasskey $delete): void
|
||||
{
|
||||
$this->ensureNotImpersonating();
|
||||
|
||||
if (RateLimiter::tooManyAttempts($throttleKey, 5)) {
|
||||
throw ValidationException::withMessages([
|
||||
'securityCode' => __('Zu viele Fehlversuche. Bitte warten Sie eine Minute.'),
|
||||
]);
|
||||
}
|
||||
/** @var User $user */
|
||||
$user = auth()->user();
|
||||
|
||||
$secret = decrypt($user->two_factor_secret);
|
||||
$this->assertSecurityProof($user);
|
||||
|
||||
if (app(TwoFactorAuthenticationProvider::class)->verify($secret, $code)) {
|
||||
RateLimiter::clear($throttleKey);
|
||||
$passkey = $user->passkeys()->whereKey($passkeyId)->firstOrFail();
|
||||
$delete($user, $passkey);
|
||||
|
||||
return;
|
||||
}
|
||||
session()->flash('security-status', __('Passkey entfernt.'));
|
||||
}
|
||||
|
||||
if (in_array($code, $user->recoveryCodes() ?? [], true)) {
|
||||
$user->replaceRecoveryCode($code);
|
||||
RateLimiter::clear($throttleKey);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
RateLimiter::hit($throttleKey, 60);
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'securityCode' => __('Der Code ist ungültig. Geben Sie einen aktuellen Code aus Ihrer Authenticator-App oder einen Wiederherstellungs-Code ein.'),
|
||||
]);
|
||||
private function ensureNotImpersonating(): void
|
||||
{
|
||||
abort_if(app(UserImpersonation::class)->isActive(), 403, __('Während der Benutzer-Impersonation können keine Passkeys verwaltet werden.'));
|
||||
}
|
||||
|
||||
public function with(): array
|
||||
|
|
@ -295,6 +292,8 @@ new #[Layout('components.layouts.app'), Title('Konto-Sicherheit')] class extends
|
|||
'twoFactorConfirmed' => $twoFactorConfirmed,
|
||||
'twoFactorQrSvg' => $qrUrl,
|
||||
'recoveryCodes' => $recoveryCodes,
|
||||
'securityProofKind' => $this->securityProofKind($user),
|
||||
'passkeys' => $user->passkeys()->latest()->get(['id', 'name', 'credential', 'last_used_at', 'created_at']),
|
||||
'sessions' => DB::table('sessions')
|
||||
->where('user_id', $user->id)
|
||||
->orderByDesc('last_activity')
|
||||
|
|
@ -434,12 +433,7 @@ new #[Layout('components.layouts.app'), Title('Konto-Sicherheit')] class extends
|
|||
</div>
|
||||
@endif
|
||||
<flux:input wire:model="email" type="email" :label="__('Neue E-Mail-Adresse')" autocomplete="email" required />
|
||||
@if ($twoFactorConfirmed)
|
||||
<flux:input wire:model="securityCode" :label="__('Code aus der App oder Wiederherstellungs-Code')"
|
||||
inputmode="numeric" autocomplete="one-time-code" placeholder="123456" required />
|
||||
@elseif ((string) $user->password !== '')
|
||||
<flux:input wire:model="securityPassword" type="password" :label="__('Aktuelles Passwort')" autocomplete="current-password" required />
|
||||
@endif
|
||||
@include('partials.security-proof')
|
||||
<p class="text-[12px] text-[color:var(--color-ink-3)] m-0">
|
||||
{{ __('Sie erhalten einen Bestätigungslink an die neue Adresse. Erst nach dem Klick wird die Änderung wirksam — Ihre bisherige Adresse bleibt bis dahin aktiv.') }}
|
||||
</p>
|
||||
|
|
@ -450,6 +444,61 @@ new #[Layout('components.layouts.app'), Title('Konto-Sicherheit')] class extends
|
|||
</article>
|
||||
</div>
|
||||
|
||||
{{-- ============== PASSKEYS (Entscheidung 5) ============== --}}
|
||||
<article class="panel"
|
||||
x-data="{ busy: false, error: null }"
|
||||
x-on:passkey-register.window="busy = true; error = null;
|
||||
const outcome = await window.PkPasskeys.register($event.detail.name);
|
||||
if (outcome.ok) { await $wire.passkeyRegistered(); } else { error = outcome.error; }
|
||||
busy = false;">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Passkeys') }}</span>
|
||||
@if ($passkeys->isNotEmpty())
|
||||
<span class="badge ok dot">{{ trans_choice(':count Passkey|:count Passkeys', $passkeys->count(), ['count' => $passkeys->count()]) }}</span>
|
||||
@endif
|
||||
</div>
|
||||
<div class="p-5 space-y-5">
|
||||
<p class="text-[13px] text-[color:var(--color-ink-2)] m-0">
|
||||
{{ __('Mit einem Passkey melden Sie sich per Fingerabdruck, Gesicht oder Geräte-PIN an — ohne Passwort und ohne Code aus der App. Passkeys gelten zugleich als zweiter Faktor und funktionieren nur auf der echten pressekonto-Seite.') }}
|
||||
</p>
|
||||
|
||||
@if ($passkeys->isNotEmpty())
|
||||
<ul class="divide-y divide-[color:var(--color-bg-rule)] border border-[color:var(--color-bg-rule)] rounded-[5px] m-0 p-0 list-none">
|
||||
@foreach ($passkeys as $passkey)
|
||||
<li class="flex flex-col gap-2 px-4 py-3 sm:flex-row sm:items-center sm:justify-between" wire:key="passkey-{{ $passkey->id }}">
|
||||
<div class="min-w-0">
|
||||
<div class="text-[13px] font-semibold text-[color:var(--color-ink)] truncate">
|
||||
{{ $passkey->name }}
|
||||
@if ($passkey->authenticator)
|
||||
<span class="font-normal text-[color:var(--color-ink-3)]">· {{ $passkey->authenticator }}</span>
|
||||
@endif
|
||||
</div>
|
||||
<div class="text-[11.5px] text-[color:var(--color-ink-3)]">
|
||||
{{ __('Hinzugefügt am :date', ['date' => $passkey->created_at?->format('d.m.Y')]) }}
|
||||
· {{ $passkey->last_used_at ? __('zuletzt genutzt :date', ['date' => $passkey->last_used_at->format('d.m.Y H:i')]) : __('noch nicht genutzt') }}
|
||||
</div>
|
||||
</div>
|
||||
<flux:button size="sm" variant="subtle" icon="trash"
|
||||
wire:click="deletePasskey({{ $passkey->id }})"
|
||||
wire:confirm="{{ __('Diesen Passkey wirklich entfernen?') }}">
|
||||
{{ __('Entfernen') }}
|
||||
</flux:button>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
@endif
|
||||
|
||||
<form wire:submit="startPasskeyRegistration" class="space-y-4 max-w-[420px]">
|
||||
<flux:input wire:model="passkeyName" :label="__('Name für den neuen Passkey')" placeholder="{{ __('z. B. MacBook, iPhone') }}" maxlength="60" required />
|
||||
@include('partials.security-proof')
|
||||
<flux:button type="submit" variant="primary" icon="finger-print" x-bind:disabled="busy">
|
||||
{{ __('Passkey hinzufügen') }}
|
||||
</flux:button>
|
||||
<p class="text-[12px] text-[color:var(--color-warn,#b91c1c)] m-0" x-show="error" x-text="error"></p>
|
||||
</form>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Zwei-Faktor-Authentifizierung') }}</span>
|
||||
|
|
@ -465,9 +514,12 @@ new #[Layout('components.layouts.app'), Title('Konto-Sicherheit')] class extends
|
|||
<p class="text-[13px] text-[color:var(--color-ink-2)] m-0">
|
||||
{{ __('Schützen Sie Ihren Account zusätzlich mit einer Authenticator-App (TOTP).') }}
|
||||
</p>
|
||||
<flux:button class="mt-4" wire:click="enableTwoFactorAuthentication" variant="primary">
|
||||
{{ __('Zwei-Faktor-Authentifizierung aktivieren') }}
|
||||
</flux:button>
|
||||
<form wire:submit="enableTwoFactorAuthentication" class="mt-4 space-y-4 max-w-[420px]">
|
||||
@include('partials.security-proof')
|
||||
<flux:button type="submit" variant="primary">
|
||||
{{ __('Zwei-Faktor-Authentifizierung aktivieren') }}
|
||||
</flux:button>
|
||||
</form>
|
||||
@elseif ($twoFactorPending)
|
||||
{{-- Zustand 2: Secret erzeugt, Bestätigung mit App-Code steht aus.
|
||||
Erst die Bestätigung aktiviert die Login-Abfrage. --}}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<?php
|
||||
|
||||
use App\Actions\Admin\UserImpersonation;
|
||||
use App\Livewire\Concerns\ConfirmsIdentity;
|
||||
use App\Services\Api\ApiAccessEligibilityService;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Livewire\Attributes\Layout;
|
||||
|
|
@ -9,6 +10,8 @@ use Livewire\Component;
|
|||
|
||||
new #[Layout('components.layouts.app'), Title('API-Tokens')] class extends Component
|
||||
{
|
||||
use ConfirmsIdentity;
|
||||
|
||||
public string $tokenName = '';
|
||||
|
||||
/** @var list<string> */
|
||||
|
|
@ -50,6 +53,10 @@ new #[Layout('components.layouts.app'), Title('API-Tokens')] class extends Compo
|
|||
'selectedAbilities.*' => ['required', 'string', Rule::in(array_keys(self::ABILITIES))],
|
||||
]);
|
||||
|
||||
// Ein Token wirkt dauerhaft und ohne Session — daher wie beim
|
||||
// E-Mail-Wechsel nur mit frischem Nachweis (Sicherheitsaudit 07.10.2026, S-06).
|
||||
$this->assertSecurityProof(auth()->user());
|
||||
|
||||
$token = auth()->user()->createToken(
|
||||
$validated['tokenName'],
|
||||
$validated['selectedAbilities'],
|
||||
|
|
@ -83,6 +90,7 @@ new #[Layout('components.layouts.app'), Title('API-Tokens')] class extends Compo
|
|||
|
||||
return [
|
||||
'abilityOptions' => self::ABILITIES,
|
||||
'securityProofKind' => $this->securityProofKind(auth()->user()),
|
||||
'canCreateApiToken' => $denialReason === null && ! $isImpersonating,
|
||||
'canRevokeApiTokens' => ! $isImpersonating,
|
||||
'apiTokenDenialReason' => $isImpersonating
|
||||
|
|
@ -191,6 +199,12 @@ new #[Layout('components.layouts.app'), Title('API-Tokens')] class extends Compo
|
|||
<flux:error name="selectedAbilities" class="mt-3" />
|
||||
</div>
|
||||
|
||||
@if ($canCreateApiToken && $securityProofKind !== 'fresh')
|
||||
<div class="max-w-[420px]">
|
||||
@include('partials.security-proof')
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="flex justify-end pt-3 border-t border-[color:var(--color-bg-rule)]">
|
||||
<flux:button type="submit" variant="primary" icon="key" :disabled="! $canCreateApiToken">
|
||||
{{ __('Token erstellen') }}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<?php
|
||||
|
||||
use App\Services\Auth\PasswordChanger;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Livewire\Attributes\Layout;
|
||||
|
|
@ -31,9 +31,7 @@ new #[Layout('components.layouts.app')] class extends Component
|
|||
throw $e;
|
||||
}
|
||||
|
||||
Auth::user()->update([
|
||||
'password' => Hash::make($validated['password']),
|
||||
]);
|
||||
app(PasswordChanger::class)->change(Auth::user(), $validated['password'], session()->driver());
|
||||
|
||||
$this->reset('current_password', 'password', 'password_confirmation');
|
||||
|
||||
|
|
|
|||
41
resources/views/partials/security-proof.blade.php
Normal file
41
resources/views/partials/security-proof.blade.php
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
{{--
|
||||
Felder für den erneuten Identitätsnachweis (App\Livewire\Concerns\ConfirmsIdentity,
|
||||
Sicherheitsaudit 07.10.2026, S-06). Erwartet $securityProofKind. Wer einen
|
||||
Passkey hat, kann stattdessen damit bestätigen (Entscheidung 5): Die
|
||||
Zeremonie gegen /passkeys/confirm markiert die Session als bestätigt
|
||||
(TrackSecondFactorEvents), danach zeigt die Komponente keinen Nachweis mehr.
|
||||
--}}
|
||||
@if ($securityProofKind === 'two_factor')
|
||||
<flux:input wire:model="securityCode" :label="__('Code aus der App oder Wiederherstellungs-Code')"
|
||||
inputmode="numeric" autocomplete="one-time-code" placeholder="123456" required />
|
||||
@elseif ($securityProofKind === 'password')
|
||||
<flux:input wire:model="securityPassword" type="password" :label="__('Aktuelles Passwort')" autocomplete="current-password" required />
|
||||
@elseif ($securityProofKind === 'email_code')
|
||||
<div class="space-y-2">
|
||||
<p class="text-[12px] text-[color:var(--color-ink-3)] m-0">
|
||||
{{ __('Zur Bestätigung senden wir einen Code an Ihre aktuelle Adresse :email.', ['email' => auth()->user()->email]) }}
|
||||
</p>
|
||||
<div class="flex items-end gap-2">
|
||||
<div class="flex-1 max-w-[200px]">
|
||||
<flux:input wire:model="securityEmailCode" :label="__('Code aus der E-Mail')"
|
||||
inputmode="numeric" autocomplete="one-time-code" placeholder="123456" />
|
||||
</div>
|
||||
<flux:button type="button" wire:click="requestSecurityEmailCode" variant="filled">
|
||||
{{ $securityEmailCodeSent ? __('Code erneut senden') : __('Code senden') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($securityProofKind !== 'fresh' && auth()->user()->passkeys()->exists())
|
||||
<div x-data="{ busy: false, error: null }" class="space-y-1">
|
||||
<flux:button type="button" size="sm" variant="subtle" icon="finger-print" x-bind:disabled="busy"
|
||||
x-on:click="busy = true; error = null;
|
||||
const outcome = await window.PkPasskeys.confirm();
|
||||
if (outcome.ok) { await $wire.$refresh(); } else { error = outcome.error; }
|
||||
busy = false;">
|
||||
{{ __('Stattdessen mit Passkey bestätigen') }}
|
||||
</flux:button>
|
||||
<p class="text-[12px] text-[color:var(--color-warn,#b91c1c)] m-0" x-show="error" x-text="error"></p>
|
||||
</div>
|
||||
@endif
|
||||
|
|
@ -7,6 +7,7 @@ use App\Http\Controllers\Admin\PressReleaseImageEvidenceController;
|
|||
use App\Http\Controllers\InvoicePdfController;
|
||||
use App\Http\Controllers\LegacyInvoicePdfController;
|
||||
use App\Http\Middleware\BlockAdminDuringImpersonation;
|
||||
use App\Http\Middleware\EnsureStrongAdminAuthentication;
|
||||
use App\Http\Middleware\EnsureUserIsAdmin;
|
||||
use App\Http\Middleware\LogSlowAdminRequests;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
|
@ -16,7 +17,7 @@ use Illuminate\Support\Facades\Route;
|
|||
// Eingeloggte Admins werden über die Auth-Pipeline direkt nach /dashboard
|
||||
// geleitet (siehe FORTIFY_HOME / LoginResponseContract).
|
||||
Route::get('dashboard', DashboardController::class)
|
||||
->middleware(['auth', 'verified', BlockAdminDuringImpersonation::class, EnsureUserIsAdmin::class, LogSlowAdminRequests::class])
|
||||
->middleware(['auth', 'verified', BlockAdminDuringImpersonation::class, EnsureUserIsAdmin::class, EnsureStrongAdminAuthentication::class, LogSlowAdminRequests::class])
|
||||
->name('dashboard');
|
||||
|
||||
// ========================================
|
||||
|
|
@ -36,7 +37,14 @@ Route::post('admin/impersonate/leave', LeaveImpersonationController::class)
|
|||
// ========================================
|
||||
// Admin-Bereich (nur Rollen admin/editor)
|
||||
// ========================================
|
||||
Route::middleware(['auth', 'verified', BlockAdminDuringImpersonation::class, EnsureUserIsAdmin::class, LogSlowAdminRequests::class])->group(function () {
|
||||
// Starke Anmeldung für Admins (Entscheidung 5, 07.10.2026): einmalige
|
||||
// Bestätigung der Session per Passkey oder Authenticator-Code, bevor der
|
||||
// Admin-Bereich öffnet (EnsureStrongAdminAuthentication).
|
||||
Route::livewire('admin/anmeldung-bestaetigen', 'auth.strong-confirmation')
|
||||
->middleware(['auth', 'verified', BlockAdminDuringImpersonation::class, EnsureUserIsAdmin::class])
|
||||
->name('strong-auth.confirm');
|
||||
|
||||
Route::middleware(['auth', 'verified', BlockAdminDuringImpersonation::class, EnsureUserIsAdmin::class, EnsureStrongAdminAuthentication::class, LogSlowAdminRequests::class])->group(function () {
|
||||
|
||||
// Content Management
|
||||
Route::livewire('admin/press-releases', 'admin.press-releases.index')->name('admin.press-releases.index');
|
||||
|
|
|
|||
|
|
@ -39,6 +39,14 @@ Route::group(['middleware' => config('fortify.middleware', ['web'])], function (
|
|||
->middleware(['guest:'.config('fortify.guard')])
|
||||
->name('oauth.google.callback');
|
||||
|
||||
// Bestätigte Erstverknüpfung eines bestehenden Kontos (Sicherheitsaudit
|
||||
// 07.10.2026, S-05): signierter Link aus der Mail, nur in der Session gültig,
|
||||
// die den Google-Login begonnen hat (SocialAccountLinker).
|
||||
Route::get('/auth/google/verknuepfen/{user}', [GoogleController::class, 'confirmLink'])
|
||||
->middleware(['guest:'.config('fortify.guard'), 'signed', 'throttle:10,1'])
|
||||
->whereNumber('user')
|
||||
->name('oauth.google.confirm-link');
|
||||
|
||||
// Registrierung mit Livewire
|
||||
Route::livewire('/register', 'auth.register')
|
||||
->middleware(['guest:'.config('fortify.guard')])
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ use App\Models\Company;
|
|||
use App\Models\Contact;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Livewire\Features\SupportLockedProperties\CannotUpdateLockedPropertyException;
|
||||
use Livewire\Livewire;
|
||||
use Tests\TestCase;
|
||||
|
||||
|
|
@ -122,3 +123,55 @@ test('admin retains access to all restricted management pages', function () {
|
|||
$this->get("https://pressekonto.test/{$path}")->assertSuccessful();
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* Sicherheitsaudit 07.10.2026, S-02: Die Firmenanzeige ist für Editoren
|
||||
* lesbar, das Umhängen eines Kontakts aber eine Schreibaktion.
|
||||
*/
|
||||
test('editor cannot attach an existing contact to another company', function () {
|
||||
/** @var TestCase $this */
|
||||
$source = Company::factory()->presseecho()->create();
|
||||
$target = Company::factory()->presseecho()->create();
|
||||
$contact = Contact::factory()->for($source)->create();
|
||||
|
||||
$this->actingAs(editorActor());
|
||||
|
||||
Livewire::test('admin.companies.show', ['id' => $target->id])
|
||||
->set('selectedExistingContactId', $contact->id)
|
||||
->assertForbidden();
|
||||
|
||||
Livewire::test('admin.companies.show', ['id' => $target->id])
|
||||
->call('attachExistingContact')
|
||||
->assertForbidden();
|
||||
|
||||
expect($contact->fresh()->company_id)->toBe($source->id);
|
||||
});
|
||||
|
||||
test('the company id of the company page cannot be changed by the client', function () {
|
||||
/** @var TestCase $this */
|
||||
$company = Company::factory()->presseecho()->create();
|
||||
$admin = User::factory()->create();
|
||||
$admin->assignRole('admin');
|
||||
|
||||
$this->actingAs($admin);
|
||||
|
||||
expect(fn () => Livewire::test('admin.companies.show', ['id' => $company->id])->set('id', $company->id + 1))
|
||||
->toThrow(CannotUpdateLockedPropertyException::class);
|
||||
});
|
||||
|
||||
test('admin can still attach an existing contact to a company', function () {
|
||||
/** @var TestCase $this */
|
||||
$source = Company::factory()->presseecho()->create();
|
||||
$target = Company::factory()->presseecho()->create();
|
||||
$contact = Contact::factory()->for($source)->create();
|
||||
$admin = User::factory()->create();
|
||||
$admin->assignRole('admin');
|
||||
|
||||
$this->actingAs($admin);
|
||||
|
||||
Livewire::test('admin.companies.show', ['id' => $target->id])
|
||||
->set('selectedExistingContactId', $contact->id)
|
||||
->assertHasNoErrors();
|
||||
|
||||
expect($contact->fresh()->company_id)->toBe($target->id);
|
||||
});
|
||||
|
|
|
|||
142
tests/Feature/Auth/AutoMembershipRevocationTest.php
Normal file
142
tests/Feature/Auth/AutoMembershipRevocationTest.php
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Company;
|
||||
use App\Models\Contact;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\ContactAccessService;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Sicherheitsaudit 07.10.2026, S-03: Automatisch über eine Kontakt- oder
|
||||
* Firmenadresse vergebene Firmenrechte enden, sobald ihre Grundlage
|
||||
* wegfällt — in der laufenden Session, nicht erst beim nächsten Login.
|
||||
* Manuelle Mitgliedschaften bleiben.
|
||||
*/
|
||||
beforeEach(function () {
|
||||
$this->seed(RolesAndPermissionsSeeder::class);
|
||||
});
|
||||
|
||||
/**
|
||||
* @return array{0: User, 1: Company, 2: Contact}
|
||||
*/
|
||||
function memberViaContact(string $email = 'ex-mitarbeiterin@firma.test'): array
|
||||
{
|
||||
$user = User::factory()->create(['email' => $email, 'is_active' => true]);
|
||||
$user->assignRole('customer');
|
||||
|
||||
$company = Company::factory()->presseecho()->create(['email' => 'zentrale@firma.test']);
|
||||
$contact = Contact::factory()->for($company)->create(['email' => $email, 'portal' => $company->portal->value]);
|
||||
|
||||
app(ContactAccessService::class)->syncMembershipsForEmail($user);
|
||||
|
||||
return [$user, $company, $contact];
|
||||
}
|
||||
|
||||
function membershipOf(User $user, Company $company): ?object
|
||||
{
|
||||
return DB::table('company_user')->where('user_id', $user->id)->where('company_id', $company->id)->first();
|
||||
}
|
||||
|
||||
test('deleting the contact ends the automatic access within the running session', function () {
|
||||
/** @var TestCase $this */
|
||||
[$user, $company, $contact] = memberViaContact();
|
||||
|
||||
expect(membershipOf($user, $company)?->granted_via)->toBe('auto');
|
||||
|
||||
$this->actingAs($user)->get(route('me.press-kits.show', $company->id))->assertSuccessful();
|
||||
|
||||
$contact->delete();
|
||||
|
||||
expect(membershipOf($user, $company))->toBeNull();
|
||||
$this->actingAs($user->fresh())->get(route('me.press-kits.show', $company->id))->assertNotFound();
|
||||
});
|
||||
|
||||
test('changing or moving the contact ends the automatic access', function (Closure $change) {
|
||||
[$user, $company, $contact] = memberViaContact();
|
||||
|
||||
$change($contact);
|
||||
|
||||
expect(membershipOf($user, $company))->toBeNull()
|
||||
->and($user->fresh()->canAccessCompany($company->id))->toBeFalse();
|
||||
})->with([
|
||||
'email changed' => fn (Contact $contact) => $contact->update(['email' => 'nachfolger@firma.test']),
|
||||
'moved to another company' => fn (Contact $contact) => $contact->update([
|
||||
'company_id' => Company::factory()->presseecho()->create()->id,
|
||||
]),
|
||||
]);
|
||||
|
||||
test('a contact moved into a company grants its automatic access right away', function () {
|
||||
$user = User::factory()->create(['email' => 'neu@firma.test', 'is_active' => true]);
|
||||
$user->assignRole('customer');
|
||||
$source = Company::factory()->presseecho()->create();
|
||||
$target = Company::factory()->presseecho()->create();
|
||||
$contact = Contact::factory()->for($source)->create(['email' => 'neu@firma.test', 'portal' => $source->portal->value]);
|
||||
|
||||
app(ContactAccessService::class)->syncMembershipsForEmail($user);
|
||||
$contact->update(['company_id' => $target->id]);
|
||||
|
||||
expect(membershipOf($user, $source))->toBeNull()
|
||||
->and(membershipOf($user, $target)?->granted_via)->toBe('auto');
|
||||
});
|
||||
|
||||
test('changing the company address ends access granted through it', function () {
|
||||
$user = User::factory()->create(['email' => 'zentrale@firma.test', 'is_active' => true]);
|
||||
$user->assignRole('customer');
|
||||
$company = Company::factory()->presseecho()->create(['email' => 'zentrale@firma.test']);
|
||||
|
||||
app(ContactAccessService::class)->syncMembershipsForEmail($user);
|
||||
expect(membershipOf($user, $company)?->granted_via)->toBe('auto');
|
||||
|
||||
$company->update(['email' => 'info@firma.test']);
|
||||
|
||||
expect(membershipOf($user, $company))->toBeNull();
|
||||
});
|
||||
|
||||
test('access stays while a second contact with the same address remains', function () {
|
||||
[$user, $company, $contact] = memberViaContact();
|
||||
Contact::factory()->for($company)->create(['email' => $user->email, 'portal' => $company->portal->value]);
|
||||
|
||||
$contact->delete();
|
||||
|
||||
expect(membershipOf($user, $company)?->granted_via)->toBe('auto');
|
||||
});
|
||||
|
||||
test('manual memberships survive and upgraded ones fall back to member', function () {
|
||||
$manual = User::factory()->create(['email' => 'manuell@firma.test', 'is_active' => true]);
|
||||
$upgraded = User::factory()->create(['email' => 'team@firma.test', 'is_active' => true]);
|
||||
$company = Company::factory()->presseecho()->create();
|
||||
$manualContact = Contact::factory()->for($company)->create(['email' => 'manuell@firma.test', 'portal' => $company->portal->value]);
|
||||
$teamContact = Contact::factory()->for($company)->create(['email' => 'team@firma.test', 'portal' => $company->portal->value]);
|
||||
|
||||
$manual->companies()->attach($company->id, ['role' => 'responsible', 'granted_via' => 'manual']);
|
||||
$upgraded->companies()->attach($company->id, ['role' => 'member', 'granted_via' => 'manual']);
|
||||
app(ContactAccessService::class)->syncMembershipsForEmail($upgraded);
|
||||
expect(membershipOf($upgraded, $company)->granted_via)->toBe('auto-upgrade');
|
||||
|
||||
$manualContact->delete();
|
||||
$teamContact->delete();
|
||||
|
||||
expect(membershipOf($manual, $company)->role)->toBe('responsible')
|
||||
->and(membershipOf($manual, $company)->granted_via)->toBe('manual')
|
||||
->and(membershipOf($upgraded, $company)->role)->toBe('member')
|
||||
->and(membershipOf($upgraded, $company)->granted_via)->toBe('manual');
|
||||
});
|
||||
|
||||
test('the resync command removes stale automatic grants left by bulk updates', function () {
|
||||
/** @var TestCase $this */
|
||||
[$user, $company, $contact] = memberViaContact();
|
||||
[$keeper, $keptCompany] = memberViaContact('bleibt@firma.test');
|
||||
|
||||
// Massen-Update am Model vorbei: kein Observer.
|
||||
DB::table('contacts')->where('id', $contact->id)->update(['email' => 'anders@firma.test']);
|
||||
expect(membershipOf($user, $company))->not->toBeNull();
|
||||
|
||||
$this->artisan('memberships:resync-auto')
|
||||
->expectsOutputToContain('Automatische Rechte vorher 2, nachher 1')
|
||||
->assertSuccessful();
|
||||
|
||||
expect(membershipOf($user, $company))->toBeNull()
|
||||
->and(membershipOf($keeper, $keptCompany)?->granted_via)->toBe('auto');
|
||||
});
|
||||
|
|
@ -1,9 +1,13 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\SecurityChange;
|
||||
use App\Mail\SecurityChangeNotice;
|
||||
use App\Mail\SocialAccountLinkConfirmation;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Laravel\Socialite\Contracts\Provider;
|
||||
use Laravel\Socialite\Facades\Socialite;
|
||||
use Laravel\Socialite\Two\User as SocialiteUser;
|
||||
|
|
@ -15,9 +19,22 @@ beforeEach(function () {
|
|||
$this->seed(RolesAndPermissionsSeeder::class);
|
||||
});
|
||||
|
||||
function fakeGoogleUser(string $id, string $email, string $name): void
|
||||
/**
|
||||
* Simuliert die Google-Antwort. Die Rohdaten tragen wie der userinfo-Endpunkt
|
||||
* `email_verified` und – bei Workspace-Konten – `hd` (Sicherheitsaudit
|
||||
* 07.10.2026, S-05).
|
||||
*/
|
||||
function fakeGoogleUser(string $id, string $email, string $name, ?bool $emailVerified = true, ?string $hostedDomain = null): void
|
||||
{
|
||||
$socialiteUser = (new SocialiteUser)->map([
|
||||
$raw = array_filter([
|
||||
'sub' => $id,
|
||||
'email' => $email,
|
||||
'name' => $name,
|
||||
'email_verified' => $emailVerified,
|
||||
'hd' => $hostedDomain,
|
||||
], fn ($value) => $value !== null);
|
||||
|
||||
$socialiteUser = (new SocialiteUser)->setRaw($raw)->map([
|
||||
'id' => $id,
|
||||
'email' => $email,
|
||||
'name' => $name,
|
||||
|
|
@ -47,21 +64,24 @@ test('a new google user is created active, verified and as customer', function (
|
|||
$this->assertAuthenticatedAs($user);
|
||||
});
|
||||
|
||||
test('an existing user is linked by email without creating a duplicate', function () {
|
||||
test('an existing gmail user is linked by email without creating a duplicate', function () {
|
||||
/** @var TestCase $this */
|
||||
$existing = User::factory()->create(['email' => 'existing-google@example.test', 'is_active' => true]);
|
||||
Mail::fake();
|
||||
$existing = User::factory()->create(['email' => 'existing-google@gmail.com', 'is_active' => true]);
|
||||
$existing->assignRole('customer');
|
||||
|
||||
fakeGoogleUser('g-existing-1', 'Existing-Google@example.test', 'Existing');
|
||||
fakeGoogleUser('g-existing-1', 'Existing-Google@gmail.com', 'Existing');
|
||||
|
||||
$this->get(route('oauth.google.callback'))
|
||||
->assertRedirect(route('me.dashboard', absolute: false));
|
||||
|
||||
expect(User::query()->where('email', 'existing-google@example.test')->count())->toBe(1);
|
||||
expect(User::query()->where('email', 'existing-google@gmail.com')->count())->toBe(1);
|
||||
$existing->refresh();
|
||||
expect($existing->oauth_provider)->toBe('google');
|
||||
expect($existing->oauth_provider_id)->toBe('g-existing-1');
|
||||
$this->assertAuthenticatedAs($existing);
|
||||
Mail::assertQueued(SecurityChangeNotice::class, fn (SecurityChangeNotice $mail) => $mail->change === SecurityChange::GoogleLinked
|
||||
&& $mail->hasTo('existing-google@gmail.com'));
|
||||
});
|
||||
|
||||
test('a deactivated verified account is not reactivated by google login', function () {
|
||||
|
|
@ -113,6 +133,8 @@ test('google login hands an account with confirmed two-factor to the challenge (
|
|||
$user = User::factory()->create(['email' => 'totp-google@example.test', 'is_active' => true]);
|
||||
$user->assignRole('customer');
|
||||
$user->forceFill([
|
||||
'oauth_provider' => 'google',
|
||||
'oauth_provider_id' => 'g-totp-1',
|
||||
'two_factor_secret' => encrypt(app(Google2FA::class)->generateSecretKey()),
|
||||
'two_factor_confirmed_at' => now(),
|
||||
])->save();
|
||||
|
|
@ -178,3 +200,163 @@ test('a pre-registered account is no longer reachable with the old password, tok
|
|||
->assertHasErrors(['email']);
|
||||
$this->assertGuest();
|
||||
});
|
||||
|
||||
/*
|
||||
* S-05 (Sicherheitsaudit 07.10.2026): Google-Identität fest binden und die
|
||||
* Erstverknüpfung bestehender Konten nur bei Googles Zuständigkeit für das
|
||||
* Postfach direkt zulassen.
|
||||
*/
|
||||
test('google login is rejected without a confirmed email address', function (?bool $emailVerified) {
|
||||
/** @var TestCase $this */
|
||||
fakeGoogleUser('g-unverified-1', 'unverified-claim@gmail.com', 'Unverified', $emailVerified);
|
||||
|
||||
$this->get(route('oauth.google.callback'))
|
||||
->assertRedirect(route('login'))
|
||||
->assertSessionHasErrors('email');
|
||||
|
||||
$this->assertGuest();
|
||||
expect(User::query()->where('email', 'unverified-claim@gmail.com')->exists())->toBeFalse();
|
||||
})->with([
|
||||
'email_verified false' => false,
|
||||
'claim missing' => null,
|
||||
]);
|
||||
|
||||
test('an account linked to another google identity is not relinked', function () {
|
||||
/** @var TestCase $this */
|
||||
$user = User::factory()->create([
|
||||
'email' => 'linked@gmail.com',
|
||||
'is_active' => true,
|
||||
'oauth_provider' => 'google',
|
||||
'oauth_provider_id' => 'g-original',
|
||||
]);
|
||||
$user->assignRole('customer');
|
||||
|
||||
fakeGoogleUser('g-other', 'linked@gmail.com', 'Other');
|
||||
|
||||
$this->get(route('oauth.google.callback'))
|
||||
->assertRedirect(route('login'))
|
||||
->assertSessionHasErrors('email');
|
||||
|
||||
$this->assertGuest();
|
||||
expect($user->fresh()->oauth_provider_id)->toBe('g-original');
|
||||
});
|
||||
|
||||
test('a linked google identity signs in even after its google address changed', function () {
|
||||
/** @var TestCase $this */
|
||||
$user = User::factory()->create([
|
||||
'email' => 'old-address@firma.test',
|
||||
'is_active' => true,
|
||||
'oauth_provider' => 'google',
|
||||
'oauth_provider_id' => 'g-stable',
|
||||
]);
|
||||
$user->assignRole('customer');
|
||||
|
||||
fakeGoogleUser('g-stable', 'new-address@gmail.com', 'Stable');
|
||||
|
||||
$this->get(route('oauth.google.callback'))
|
||||
->assertRedirect(route('me.dashboard', absolute: false));
|
||||
|
||||
$this->assertAuthenticatedAs($user);
|
||||
expect($user->fresh()->email)->toBe('old-address@firma.test');
|
||||
});
|
||||
|
||||
test('an existing account with an external address is only linked after mailbox confirmation in the same browser', function () {
|
||||
/** @var TestCase $this */
|
||||
Mail::fake();
|
||||
$user = User::factory()->create(['email' => 'presse@firma.test', 'is_active' => true]);
|
||||
$user->assignRole('customer');
|
||||
|
||||
fakeGoogleUser('g-external', 'presse@firma.test', 'Presse');
|
||||
|
||||
$this->get(route('oauth.google.callback'))
|
||||
->assertRedirect(route('login'))
|
||||
->assertSessionHas('status');
|
||||
|
||||
$this->assertGuest();
|
||||
expect($user->fresh()->oauth_provider_id)->toBeNull();
|
||||
|
||||
$confirmUrl = null;
|
||||
Mail::assertQueued(SocialAccountLinkConfirmation::class, function (SocialAccountLinkConfirmation $mail) use (&$confirmUrl) {
|
||||
$confirmUrl = $mail->confirmUrl;
|
||||
|
||||
return $mail->hasTo('presse@firma.test');
|
||||
});
|
||||
|
||||
$this->get($confirmUrl)->assertRedirect(route('me.dashboard', absolute: false));
|
||||
|
||||
$this->assertAuthenticatedAs($user);
|
||||
expect($user->fresh()->oauth_provider_id)->toBe('g-external');
|
||||
Mail::assertQueued(SecurityChangeNotice::class, fn (SecurityChangeNotice $mail) => $mail->change === SecurityChange::GoogleLinked);
|
||||
});
|
||||
|
||||
test('the link confirmation does nothing in a browser that did not start the google login', function () {
|
||||
/** @var TestCase $this */
|
||||
Mail::fake();
|
||||
$user = User::factory()->create(['email' => 'presse2@firma.test', 'is_active' => true]);
|
||||
$user->assignRole('customer');
|
||||
|
||||
fakeGoogleUser('g-attacker', 'presse2@firma.test', 'Angreifer');
|
||||
$this->get(route('oauth.google.callback'));
|
||||
|
||||
$confirmUrl = null;
|
||||
Mail::assertQueued(SocialAccountLinkConfirmation::class, function (SocialAccountLinkConfirmation $mail) use (&$confirmUrl) {
|
||||
$confirmUrl = $mail->confirmUrl;
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// Der Postfachinhaber klickt die Mail in seinem eigenen Browser an.
|
||||
$this->flushSession();
|
||||
|
||||
$this->get($confirmUrl)
|
||||
->assertRedirect(route('login'))
|
||||
->assertSessionHasErrors('email');
|
||||
|
||||
$this->assertGuest();
|
||||
expect($user->fresh()->oauth_provider_id)->toBeNull();
|
||||
});
|
||||
|
||||
test('a tampered link confirmation is rejected', function () {
|
||||
/** @var TestCase $this */
|
||||
Mail::fake();
|
||||
$user = User::factory()->create(['email' => 'presse3@firma.test', 'is_active' => true]);
|
||||
$user->assignRole('customer');
|
||||
|
||||
fakeGoogleUser('g-real', 'presse3@firma.test', 'Presse');
|
||||
$this->get(route('oauth.google.callback'));
|
||||
|
||||
$confirmUrl = null;
|
||||
Mail::assertQueued(SocialAccountLinkConfirmation::class, function (SocialAccountLinkConfirmation $mail) use (&$confirmUrl) {
|
||||
$confirmUrl = $mail->confirmUrl;
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
$this->get(str_replace('g-real', 'g-forged', $confirmUrl))->assertForbidden();
|
||||
|
||||
expect($user->fresh()->oauth_provider_id)->toBeNull();
|
||||
});
|
||||
|
||||
test('a workspace account is linked directly when hd matches the address domain', function (string $hostedDomain, bool $linkedDirectly) {
|
||||
/** @var TestCase $this */
|
||||
Mail::fake();
|
||||
$user = User::factory()->create(['email' => 'team@workspace-firma.test', 'is_active' => true]);
|
||||
$user->assignRole('customer');
|
||||
|
||||
fakeGoogleUser('g-workspace', 'team@workspace-firma.test', 'Team', hostedDomain: $hostedDomain);
|
||||
|
||||
$this->get(route('oauth.google.callback'));
|
||||
|
||||
if ($linkedDirectly) {
|
||||
$this->assertAuthenticatedAs($user);
|
||||
expect($user->fresh()->oauth_provider_id)->toBe('g-workspace');
|
||||
Mail::assertNotQueued(SocialAccountLinkConfirmation::class);
|
||||
} else {
|
||||
$this->assertGuest();
|
||||
expect($user->fresh()->oauth_provider_id)->toBeNull();
|
||||
Mail::assertQueued(SocialAccountLinkConfirmation::class);
|
||||
}
|
||||
})->with([
|
||||
'matching hd' => ['workspace-firma.test', true],
|
||||
'foreign hd' => ['andere-firma.test', false],
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -108,7 +108,9 @@ test('google no longer forces a remember cookie, also not through the two-factor
|
|||
['user' => $user, 'secret' => $secret] = loginCompletionCustomer(twoFactor: true);
|
||||
|
||||
$provider = Mockery::mock(Provider::class);
|
||||
$provider->shouldReceive('user')->andReturn((new SocialiteUser)->map(['id' => 'g-lc-1', 'email' => $user->email, 'name' => 'Kunde']));
|
||||
// Bereits verknüpftes Konto; die Erstverknüpfung prüft GoogleLoginTest (S-05).
|
||||
$user->forceFill(['oauth_provider' => 'google', 'oauth_provider_id' => 'g-lc-1'])->save();
|
||||
$provider->shouldReceive('user')->andReturn((new SocialiteUser)->setRaw(['email_verified' => true])->map(['id' => 'g-lc-1', 'email' => $user->email, 'name' => 'Kunde']));
|
||||
Socialite::shouldReceive('driver')->with('google')->andReturn($provider);
|
||||
|
||||
$this->get(route('oauth.google.callback'))->assertRedirect(route('two-factor.challenge'));
|
||||
|
|
@ -127,7 +129,9 @@ test('google without two-factor logs in without a remember cookie', function ()
|
|||
['user' => $user] = loginCompletionCustomer();
|
||||
|
||||
$provider = Mockery::mock(Provider::class);
|
||||
$provider->shouldReceive('user')->andReturn((new SocialiteUser)->map(['id' => 'g-lc-2', 'email' => $user->email, 'name' => 'Kunde']));
|
||||
// Bereits verknüpftes Konto; die Erstverknüpfung prüft GoogleLoginTest (S-05).
|
||||
$user->forceFill(['oauth_provider' => 'google', 'oauth_provider_id' => 'g-lc-2'])->save();
|
||||
$provider->shouldReceive('user')->andReturn((new SocialiteUser)->setRaw(['email_verified' => true])->map(['id' => 'g-lc-2', 'email' => $user->email, 'name' => 'Kunde']));
|
||||
Socialite::shouldReceive('driver')->with('google')->andReturn($provider);
|
||||
|
||||
$response = $this->get(route('oauth.google.callback'));
|
||||
|
|
|
|||
376
tests/Feature/Auth/PasskeyAndStrongAdminAuthTest.php
Normal file
376
tests/Feature/Auth/PasskeyAndStrongAdminAuthTest.php
Normal file
|
|
@ -0,0 +1,376 @@
|
|||
<?php
|
||||
|
||||
use App\Actions\Admin\UserImpersonation;
|
||||
use App\Enums\SecurityChange;
|
||||
use App\Http\Responses\PasskeyLoginResponse;
|
||||
use App\Mail\SecurityChangeNotice;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\SecurityConfirmation;
|
||||
use App\Services\Auth\StrongAuthentication;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Fortify\Events\ValidTwoFactorAuthenticationCodeProvided;
|
||||
use Laravel\Passkeys\Events\PasskeyDeleted;
|
||||
use Laravel\Passkeys\Events\PasskeyRegistered;
|
||||
use Laravel\Passkeys\Events\PasskeyVerified;
|
||||
use Laravel\Passkeys\Passkey;
|
||||
use Laravel\Passkeys\Passkeys;
|
||||
use Livewire\Livewire;
|
||||
use PragmaRX\Google2FA\Google2FA;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Entscheidung 5 (07.10.2026): Passkeys als bequemer, starker Login für alle
|
||||
* und als Pflicht-Zweitfaktor (alternativ Authenticator-App) für Super-Admins
|
||||
* und Admins im Admin-Bereich. Die WebAuthn-Zeremonie selbst prüft das Paket
|
||||
* laravel/passkeys; hier geht es um die Anbindung an die eigenen Schutzschichten.
|
||||
*/
|
||||
beforeEach(function () {
|
||||
$this->seed(RolesAndPermissionsSeeder::class);
|
||||
Mail::fake();
|
||||
});
|
||||
|
||||
function userWithPasskeyRole(string $role, array $attributes = []): User
|
||||
{
|
||||
$user = User::factory()->create(['is_active' => true, ...$attributes]);
|
||||
$user->assignRole($role);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
function passkeyFor(User $user): Passkey
|
||||
{
|
||||
return $user->passkeys()->create([
|
||||
'name' => 'MacBook',
|
||||
'credential_id' => Str::random(32),
|
||||
'credential' => ['aaguid' => '00000000-0000-0000-0000-000000000000'],
|
||||
]);
|
||||
}
|
||||
|
||||
function withTotp(User $user): string
|
||||
{
|
||||
$secret = app(Google2FA::class)->generateSecretKey();
|
||||
$user->forceFill([
|
||||
'two_factor_secret' => encrypt($secret),
|
||||
'two_factor_recovery_codes' => encrypt(json_encode(['recovery-eins'])),
|
||||
'two_factor_confirmed_at' => now(),
|
||||
])->save();
|
||||
|
||||
return $secret;
|
||||
}
|
||||
|
||||
/*
|
||||
* Pflicht im Admin-Bereich
|
||||
*/
|
||||
test('an admin without a confirmed second factor is sent to the confirmation page and back', function () {
|
||||
/** @var TestCase $this */
|
||||
config(['auth.require_strong_admin_authentication' => true]);
|
||||
$admin = userWithPasskeyRole('admin');
|
||||
$secret = withTotp($admin);
|
||||
|
||||
$this->actingAs($admin)
|
||||
->get('https://pressekonto.test/admin/companies')
|
||||
->assertRedirect(route('strong-auth.confirm'));
|
||||
|
||||
Livewire::test('auth.strong-confirmation')
|
||||
->set('securityCode', app(Google2FA::class)->getCurrentOtp($secret))
|
||||
->call('confirmWithCode')
|
||||
->assertHasNoErrors()
|
||||
->assertRedirect('https://pressekonto.test/admin/companies');
|
||||
|
||||
$this->get('https://pressekonto.test/admin/companies')->assertSuccessful();
|
||||
});
|
||||
|
||||
test('a wrong authenticator code does not confirm the admin session', function () {
|
||||
config(['auth.require_strong_admin_authentication' => true]);
|
||||
$admin = userWithPasskeyRole('admin');
|
||||
withTotp($admin);
|
||||
|
||||
$this->actingAs($admin);
|
||||
|
||||
Livewire::test('auth.strong-confirmation')
|
||||
->set('securityCode', '000000')
|
||||
->call('confirmWithCode')
|
||||
->assertHasErrors(['securityCode']);
|
||||
|
||||
expect(app(StrongAuthentication::class)->isSatisfiedBy($admin))->toBeFalse();
|
||||
});
|
||||
|
||||
test('magic link, google or password alone never satisfy the admin requirement', function () {
|
||||
/** @var TestCase $this */
|
||||
config(['auth.require_strong_admin_authentication' => true]);
|
||||
$superAdmin = userWithPasskeyRole('admin', ['is_super_admin' => true]);
|
||||
|
||||
$this->actingAs($superAdmin)->get(route('dashboard'))->assertRedirect(route('strong-auth.confirm'));
|
||||
$this->get('https://pressekonto.test/admin/companies')->assertRedirect(route('strong-auth.confirm'));
|
||||
});
|
||||
|
||||
test('without any second factor the confirmation page leads to the setup', function () {
|
||||
/** @var TestCase $this */
|
||||
config(['auth.require_strong_admin_authentication' => true]);
|
||||
$this->actingAs(userWithPasskeyRole('admin'));
|
||||
|
||||
$this->get(route('strong-auth.confirm'))
|
||||
->assertSuccessful()
|
||||
->assertSee('Zweiten Faktor einrichten')
|
||||
->assertSee(route('me.security'));
|
||||
});
|
||||
|
||||
test('editors and customers are not affected by the admin requirement', function () {
|
||||
/** @var TestCase $this */
|
||||
config(['auth.require_strong_admin_authentication' => true]);
|
||||
|
||||
$this->actingAs(userWithPasskeyRole('editor'))
|
||||
->get('https://pressekonto.test/admin/press-releases')
|
||||
->assertSuccessful();
|
||||
|
||||
$this->actingAs(userWithPasskeyRole('customer'))
|
||||
->get(route('me.security'))
|
||||
->assertSuccessful();
|
||||
});
|
||||
|
||||
test('the emergency switch disables the admin requirement', function () {
|
||||
/** @var TestCase $this */
|
||||
config(['auth.require_strong_admin_authentication' => false]);
|
||||
|
||||
$this->actingAs(userWithPasskeyRole('admin'))
|
||||
->get('https://pressekonto.test/admin/companies')
|
||||
->assertSuccessful();
|
||||
});
|
||||
|
||||
test('livewire requests of an unconfirmed admin session are rejected', function () {
|
||||
/** @var TestCase $this */
|
||||
config(['auth.require_strong_admin_authentication' => true]);
|
||||
$this->actingAs(userWithPasskeyRole('admin'));
|
||||
|
||||
$this->getJson('https://pressekonto.test/admin/companies')->assertForbidden();
|
||||
});
|
||||
|
||||
/*
|
||||
* Passkey- und 2FA-Ereignisse
|
||||
*/
|
||||
test('a verified passkey confirms the session strongly and counts as identity proof', function () {
|
||||
/** @var TestCase $this */
|
||||
config(['auth.require_strong_admin_authentication' => true]);
|
||||
$admin = userWithPasskeyRole('admin');
|
||||
$passkey = passkeyFor($admin);
|
||||
|
||||
$this->actingAs($admin);
|
||||
PasskeyVerified::dispatch($admin, $passkey);
|
||||
|
||||
expect(app(StrongAuthentication::class)->isSatisfiedBy($admin))->toBeTrue()
|
||||
->and(app(SecurityConfirmation::class)->isFresh($admin))->toBeTrue();
|
||||
|
||||
Livewire::test('auth.strong-confirmation')->assertRedirect(route('dashboard'));
|
||||
$this->get('https://pressekonto.test/admin/companies')->assertSuccessful();
|
||||
});
|
||||
|
||||
test('the passkey confirmation callback fails without a verified passkey', function () {
|
||||
$admin = userWithPasskeyRole('admin');
|
||||
passkeyFor($admin);
|
||||
$this->actingAs($admin);
|
||||
|
||||
Livewire::test('auth.strong-confirmation')
|
||||
->call('passkeyConfirmed')
|
||||
->assertHasErrors(['passkey']);
|
||||
});
|
||||
|
||||
test('a valid code in the two-factor challenge confirms the session strongly', function () {
|
||||
$admin = userWithPasskeyRole('admin');
|
||||
$this->actingAs($admin);
|
||||
|
||||
event(new ValidTwoFactorAuthenticationCodeProvided($admin));
|
||||
|
||||
expect(app(StrongAuthentication::class)->isSatisfiedBy($admin))->toBeTrue();
|
||||
});
|
||||
|
||||
test('added and removed passkeys are reported to the account address', function () {
|
||||
$user = userWithPasskeyRole('customer', ['email' => 'passkey@example.com']);
|
||||
$passkey = passkeyFor($user);
|
||||
|
||||
PasskeyRegistered::dispatch($user, $passkey);
|
||||
PasskeyDeleted::dispatch($user, $passkey);
|
||||
|
||||
Mail::assertQueued(SecurityChangeNotice::class, fn (SecurityChangeNotice $mail) => $mail->change === SecurityChange::PasskeyAdded && $mail->hasTo('passkey@example.com'));
|
||||
Mail::assertQueued(SecurityChangeNotice::class, fn (SecurityChangeNotice $mail) => $mail->change === SecurityChange::PasskeyRemoved);
|
||||
});
|
||||
|
||||
/*
|
||||
* Passkey-Login
|
||||
*/
|
||||
test('only active and verified accounts may sign in with a passkey', function (array $attributes, bool $allowed) {
|
||||
$user = userWithPasskeyRole('customer', $attributes);
|
||||
$passkey = passkeyFor($user);
|
||||
|
||||
expect(Passkeys::allowsLogin(Request::create('/passkeys/login', 'POST'), $passkey->fresh()))->toBe($allowed);
|
||||
})->with([
|
||||
'active and verified' => [[], true],
|
||||
'deactivated' => [['is_active' => false], false],
|
||||
'unverified' => [['email_verified_at' => null], false],
|
||||
]);
|
||||
|
||||
test('the passkey login response records the login and keeps customers out of admin targets', function () {
|
||||
/** @var TestCase $this */
|
||||
$user = userWithPasskeyRole('customer');
|
||||
$this->actingAs($user);
|
||||
|
||||
$request = Request::create('/passkeys/login', 'POST', server: ['HTTP_ACCEPT' => 'application/json']);
|
||||
$request->setLaravelSession(app('session.store'));
|
||||
$request->setUserResolver(fn () => $user);
|
||||
session()->put('url.intended', 'https://pressekonto.test/admin/companies');
|
||||
|
||||
$response = (new PasskeyLoginResponse)->toResponse($request);
|
||||
|
||||
expect($response->getData(true)['redirect'])->not->toContain('/admin/companies')
|
||||
->and($user->fresh()->last_login_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
/*
|
||||
* Passkeys verwalten
|
||||
*/
|
||||
test('passkey registration options require a fresh identity proof', function () {
|
||||
/** @var TestCase $this */
|
||||
$user = userWithPasskeyRole('customer');
|
||||
|
||||
$this->actingAs($user)
|
||||
->getJson('https://pressekonto.test/user/passkeys/options')
|
||||
->assertForbidden();
|
||||
|
||||
$this->withSession([SecurityConfirmation::SESSION_KEY => ['user_id' => $user->id, 'at' => now()->getTimestamp()]])
|
||||
->getJson('https://pressekonto.test/user/passkeys/options')
|
||||
->assertSuccessful()
|
||||
->assertJsonStructure(['options' => ['challenge', 'rp', 'user']]);
|
||||
});
|
||||
|
||||
test('passkeys cannot be registered during impersonation', function () {
|
||||
/** @var TestCase $this */
|
||||
$admin = userWithPasskeyRole('admin');
|
||||
$user = userWithPasskeyRole('customer');
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([
|
||||
UserImpersonation::SessionKey => $admin->id,
|
||||
SecurityConfirmation::SESSION_KEY => ['user_id' => $user->id, 'at' => now()->getTimestamp()],
|
||||
])
|
||||
->getJson('https://pressekonto.test/user/passkeys/options')
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
test('the unused fortify passkey delete route is blocked', function () {
|
||||
/** @var TestCase $this */
|
||||
$user = userWithPasskeyRole('customer');
|
||||
$passkey = passkeyFor($user);
|
||||
|
||||
$this->actingAs($user)
|
||||
->deleteJson("https://pressekonto.test/user/passkeys/{$passkey->id}")
|
||||
->assertNotFound();
|
||||
|
||||
expect($passkey->fresh())->not->toBeNull();
|
||||
});
|
||||
|
||||
test('the security page starts a passkey registration only after the identity proof', function () {
|
||||
$user = userWithPasskeyRole('customer');
|
||||
$this->actingAs($user);
|
||||
|
||||
Livewire::test('customer.security')
|
||||
->set('passkeyName', 'iPhone')
|
||||
->call('startPasskeyRegistration')
|
||||
->assertHasErrors(['securityPassword'])
|
||||
->assertNotDispatched('passkey-register');
|
||||
|
||||
Livewire::test('customer.security')
|
||||
->set('passkeyName', 'iPhone')
|
||||
->set('securityPassword', 'password')
|
||||
->call('startPasskeyRegistration')
|
||||
->assertHasNoErrors()
|
||||
->assertDispatched('passkey-register', name: 'iPhone');
|
||||
});
|
||||
|
||||
test('removing a passkey needs the identity proof and reports it', function () {
|
||||
$user = userWithPasskeyRole('customer');
|
||||
$passkey = passkeyFor($user);
|
||||
$foreign = passkeyFor(userWithPasskeyRole('customer'));
|
||||
$this->actingAs($user);
|
||||
|
||||
Livewire::test('customer.security')
|
||||
->call('deletePasskey', $passkey->id)
|
||||
->assertHasErrors(['securityPassword']);
|
||||
|
||||
expect($passkey->fresh())->not->toBeNull();
|
||||
|
||||
Livewire::test('customer.security')
|
||||
->set('securityPassword', 'password')
|
||||
->call('deletePasskey', $passkey->id)
|
||||
->assertHasNoErrors();
|
||||
|
||||
expect($passkey->fresh())->toBeNull();
|
||||
Mail::assertQueued(SecurityChangeNotice::class, fn (SecurityChangeNotice $mail) => $mail->change === SecurityChange::PasskeyRemoved);
|
||||
|
||||
Livewire::test('customer.security')
|
||||
->call('deletePasskey', $foreign->id)
|
||||
->assertNotFound();
|
||||
|
||||
expect($foreign->fresh())->not->toBeNull();
|
||||
});
|
||||
|
||||
test('a verified passkey replaces the password for a security change', function () {
|
||||
$user = userWithPasskeyRole('customer', ['email' => 'alt-passkey@example.com']);
|
||||
$passkey = passkeyFor($user);
|
||||
$this->actingAs($user);
|
||||
|
||||
PasskeyVerified::dispatch($user, $passkey);
|
||||
|
||||
Livewire::test('customer.security')
|
||||
->set('email', 'neu-passkey@example.com')
|
||||
->call('updateEmail')
|
||||
->assertHasNoErrors();
|
||||
|
||||
expect($user->fresh()->pending_email)->toBe('neu-passkey@example.com');
|
||||
});
|
||||
|
||||
/*
|
||||
* Notfall-Reset durch Super-Admins
|
||||
*/
|
||||
test('a super admin resets passkeys and authenticator of another account', function () {
|
||||
/** @var TestCase $this */
|
||||
$superAdmin = userWithPasskeyRole('admin', ['is_super_admin' => true]);
|
||||
$target = userWithPasskeyRole('admin', ['email' => 'verloren@example.com']);
|
||||
passkeyFor($target);
|
||||
withTotp($target);
|
||||
|
||||
$this->actingAs($superAdmin);
|
||||
|
||||
Livewire::test('admin.users.edit', ['id' => $target->id])
|
||||
->call('resetSecondFactors')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$target->refresh();
|
||||
expect($target->passkeys()->count())->toBe(0)
|
||||
->and($target->hasEnabledTwoFactorAuthentication())->toBeFalse();
|
||||
|
||||
Mail::assertQueued(SecurityChangeNotice::class, fn (SecurityChangeNotice $mail) => $mail->change === SecurityChange::SecondFactorsReset
|
||||
&& $mail->hasTo('verloren@example.com'));
|
||||
});
|
||||
|
||||
test('only super admins may reset second factors and never their own', function () {
|
||||
$admin = userWithPasskeyRole('admin');
|
||||
$superAdmin = userWithPasskeyRole('admin', ['is_super_admin' => true]);
|
||||
$target = userWithPasskeyRole('customer');
|
||||
passkeyFor($target);
|
||||
passkeyFor($superAdmin);
|
||||
|
||||
$this->actingAs($admin);
|
||||
Livewire::test('admin.users.edit', ['id' => $target->id])
|
||||
->call('resetSecondFactors')
|
||||
->assertForbidden();
|
||||
|
||||
$this->actingAs($superAdmin);
|
||||
Livewire::test('admin.users.edit', ['id' => $superAdmin->id])
|
||||
->call('resetSecondFactors')
|
||||
->assertForbidden();
|
||||
|
||||
expect($target->passkeys()->count())->toBe(1)
|
||||
->and($superAdmin->passkeys()->count())->toBe(1);
|
||||
});
|
||||
133
tests/Feature/Auth/PasswordChangeRevokesSessionsTest.php
Normal file
133
tests/Feature/Auth/PasswordChangeRevokesSessionsTest.php
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\SecurityChange;
|
||||
use App\Mail\SecurityChangeNotice;
|
||||
use App\Models\MagicLink;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\SessionRevocation;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Illuminate\Support\Str;
|
||||
use Livewire\Livewire;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Sicherheitsaudit 07.10.2026, S-04: Nach Passwortwechsel oder -Reset darf eine
|
||||
* vorher aufgebaute Browser-Session nicht weiterarbeiten. Beim angemeldeten
|
||||
* Wechsel bleibt die eigene Session angemeldet (Entscheidung 2), API-Tokens
|
||||
* bleiben bestehen (Entscheidung 1).
|
||||
*/
|
||||
beforeEach(function () {
|
||||
$this->seed(RolesAndPermissionsSeeder::class);
|
||||
Mail::fake();
|
||||
});
|
||||
|
||||
function accountWithOtherSession(): array
|
||||
{
|
||||
$user = User::factory()->create(['is_active' => true, 'remember_token' => 'old-remember-token']);
|
||||
$user->assignRole('admin');
|
||||
$user->createToken('Integration', ['press-releases:read']);
|
||||
|
||||
// Anmeldezeitpunkt der „anderen“ Session: vor dem Passwortwechsel.
|
||||
$otherSessionStamp = now()->getTimestampMs();
|
||||
test()->travel(2)->seconds();
|
||||
|
||||
return [$user, $otherSessionStamp];
|
||||
}
|
||||
|
||||
test('changing the password keeps the current session and ends every other one', function (string $component) {
|
||||
/** @var TestCase $this */
|
||||
[$user, $otherSessionStamp] = accountWithOtherSession();
|
||||
|
||||
$this->actingAs($user)->withSession([SessionRevocation::SESSION_KEY => $otherSessionStamp]);
|
||||
|
||||
Livewire::test($component)
|
||||
->set('current_password', 'password')
|
||||
->set('password', 'new-strong-password')
|
||||
->set('password_confirmation', 'new-strong-password')
|
||||
->call('updatePassword')
|
||||
->assertHasNoErrors();
|
||||
|
||||
// Die Session, in der das Passwort geändert wurde, bleibt angemeldet.
|
||||
$this->get(route('me.security'))->assertSuccessful();
|
||||
|
||||
// Eine vorher angemeldete Session fliegt beim nächsten Request raus.
|
||||
$this->withSession([SessionRevocation::SESSION_KEY => $otherSessionStamp])
|
||||
->get(route('me.security'))
|
||||
->assertRedirect(route('login'));
|
||||
$this->assertGuest();
|
||||
|
||||
$user->refresh();
|
||||
expect($user->remember_token)->not->toBe('old-remember-token')
|
||||
->and($user->tokens()->count())->toBe(1);
|
||||
|
||||
Mail::assertQueued(SecurityChangeNotice::class, fn (SecurityChangeNotice $mail) => $mail->change === SecurityChange::PasswordChanged
|
||||
&& $mail->hasTo($user->email));
|
||||
})->with([
|
||||
'customer security page' => 'customer.security',
|
||||
'admin settings page' => 'settings.password',
|
||||
]);
|
||||
|
||||
test('a password reset ends every session and voids open login links', function () {
|
||||
/** @var TestCase $this */
|
||||
[$user, $otherSessionStamp] = accountWithOtherSession();
|
||||
|
||||
$openLink = MagicLink::query()->create([
|
||||
'user_id' => $user->id,
|
||||
'token_hash' => hash('sha256', Str::random(40)),
|
||||
'purpose' => MagicLink::PURPOSE_LOGIN,
|
||||
'expires_at' => now()->addHour(),
|
||||
]);
|
||||
|
||||
Livewire::test('auth.reset-password', ['token' => Password::createToken($user)])
|
||||
->set('email', $user->email)
|
||||
->set('password', 'new-strong-password')
|
||||
->set('password_confirmation', 'new-strong-password')
|
||||
->call('resetPassword')
|
||||
->assertHasNoErrors()
|
||||
->assertRedirect(route('login', absolute: false));
|
||||
|
||||
$this->actingAs($user->fresh())
|
||||
->withSession([SessionRevocation::SESSION_KEY => $otherSessionStamp])
|
||||
->get(route('me.security'))
|
||||
->assertRedirect(route('login'));
|
||||
$this->assertGuest();
|
||||
|
||||
expect($openLink->fresh()->consumed_at)->not->toBeNull()
|
||||
->and($user->fresh()->remember_token)->not->toBe('old-remember-token')
|
||||
->and($user->tokens()->count())->toBe(1);
|
||||
|
||||
Mail::assertQueued(SecurityChangeNotice::class, fn (SecurityChangeNotice $mail) => $mail->change === SecurityChange::PasswordReset);
|
||||
});
|
||||
|
||||
test('a session started after the reset is not affected', function () {
|
||||
/** @var TestCase $this */
|
||||
[$user] = accountWithOtherSession();
|
||||
|
||||
Livewire::test('auth.reset-password', ['token' => Password::createToken($user)])
|
||||
->set('email', $user->email)
|
||||
->set('password', 'new-strong-password')
|
||||
->set('password_confirmation', 'new-strong-password')
|
||||
->call('resetPassword');
|
||||
|
||||
$this->travel(1)->seconds();
|
||||
|
||||
$this->actingAs($user->fresh())
|
||||
->withSession([SessionRevocation::SESSION_KEY => now()->getTimestampMs()])
|
||||
->get(route('me.security'))
|
||||
->assertSuccessful();
|
||||
});
|
||||
|
||||
test('the unused fortify password update route is disabled (Entscheidung 4)', function () {
|
||||
expect(Route::has('user-password.update'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('livewire requests pass the session revocation check', function () {
|
||||
$route = collect(Route::getRoutes()->getRoutes())
|
||||
->first(fn ($route) => str_ends_with((string) $route->getName(), 'livewire.update'));
|
||||
|
||||
expect($route)->not->toBeNull()
|
||||
->and($route->gatherMiddleware())->toContain('web');
|
||||
});
|
||||
89
tests/Feature/Billing/LegacyInvoiceAccessTest.php
Normal file
89
tests/Feature/Billing/LegacyInvoiceAccessTest.php
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
<?php
|
||||
|
||||
use App\Models\LegacyInvoice;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Sicherheitsaudit 07.10.2026, S-01: Fremde Alt-Rechnungen nur mit
|
||||
* `billing:manage` — der Panel-Zugang der Rolle `editor` reicht nicht, auf
|
||||
* keiner der beiden Downloadrouten.
|
||||
*/
|
||||
beforeEach(function () {
|
||||
$this->seed(RolesAndPermissionsSeeder::class);
|
||||
});
|
||||
|
||||
function legacyInvoiceFor(?User $owner, int $legacyId): LegacyInvoice
|
||||
{
|
||||
return LegacyInvoice::query()->create([
|
||||
'legacy_portal' => 'presseecho',
|
||||
'legacy_id' => $legacyId,
|
||||
'user_id' => $owner?->id,
|
||||
'legacy_user_id' => 900 + $legacyId,
|
||||
'number' => 'PE-'.$legacyId,
|
||||
'amount_cents' => 9900,
|
||||
'total_cents' => 9900,
|
||||
'status' => 'paid',
|
||||
'invoice_date' => now()->subMonth(),
|
||||
'paid_at' => now()->subMonth()->addDays(2),
|
||||
'payment_method' => 'SPK_Berlin',
|
||||
'raw_snapshot' => [
|
||||
'number' => 'PE-'.$legacyId,
|
||||
'service_period_begin_date' => now()->subYear()->toDateString(),
|
||||
'service_period_end_date' => now()->toDateString(),
|
||||
],
|
||||
'pdf_payload' => [
|
||||
'billing_address' => [
|
||||
'name' => 'Kunde GmbH',
|
||||
'address' => 'Kundenstrasse 1',
|
||||
'postal_code' => '10115',
|
||||
'city' => 'Berlin',
|
||||
'country_name' => 'Deutschland',
|
||||
],
|
||||
],
|
||||
'imported_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
function userWithRole(string $role): User
|
||||
{
|
||||
$user = User::factory()->create(['is_active' => true]);
|
||||
$user->assignRole($role);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
test('an editor cannot download a foreign legacy invoice on either route', function () {
|
||||
/** @var TestCase $this */
|
||||
$invoice = legacyInvoiceFor(userWithRole('customer'), 7001);
|
||||
|
||||
$this->actingAs(userWithRole('editor'));
|
||||
|
||||
$this->get(route('me.invoices.pdf', $invoice))->assertForbidden();
|
||||
$this->get(route('admin.legacy-invoices.pdf', $invoice))->assertForbidden();
|
||||
|
||||
expect($invoice->fresh()->pdf_generated_at)->toBeNull();
|
||||
});
|
||||
|
||||
test('a customer downloads only their own legacy invoice', function () {
|
||||
/** @var TestCase $this */
|
||||
$customer = userWithRole('customer');
|
||||
$own = legacyInvoiceFor($customer, 7002);
|
||||
$foreign = legacyInvoiceFor(userWithRole('customer'), 7003);
|
||||
|
||||
$this->actingAs($customer);
|
||||
|
||||
$this->get(route('me.invoices.pdf', $own))->assertSuccessful();
|
||||
$this->get(route('me.invoices.pdf', $foreign))->assertForbidden();
|
||||
});
|
||||
|
||||
test('a billing admin downloads foreign legacy invoices on both routes', function () {
|
||||
/** @var TestCase $this */
|
||||
$invoice = legacyInvoiceFor(userWithRole('customer'), 7004);
|
||||
|
||||
$this->actingAs(userWithRole('admin'));
|
||||
|
||||
$this->get(route('me.invoices.pdf', $invoice))->assertSuccessful();
|
||||
$this->get(route('admin.legacy-invoices.pdf', $invoice))->assertSuccessful();
|
||||
});
|
||||
|
|
@ -27,6 +27,7 @@ test('customer can create and revoke api tokens with selected abilities', functi
|
|||
->assertSee('API-Tokens')
|
||||
->set('tokenName', 'Website Integration')
|
||||
->set('selectedAbilities', ['press-releases:read', 'companies:read'])
|
||||
->set('securityPassword', 'password')
|
||||
->call('createToken')
|
||||
->assertHasNoErrors()
|
||||
->assertSee('Token wurde erstellt');
|
||||
|
|
@ -305,3 +306,31 @@ test('customer can open generated legacy invoice pdf inline from archive data',
|
|||
|
||||
expect($invoice->refresh()->pdf_generated_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
test('creating an api token requires a fresh identity proof (Sicherheitsaudit S-06)', function () {
|
||||
/** @var TestCase $this */
|
||||
$customer = User::factory()->create(['is_active' => true]);
|
||||
UserPaymentOption::factory()->create([
|
||||
'user_id' => $customer->id,
|
||||
'status' => UserPaymentOptionStatus::Active->value,
|
||||
'current_period_start' => now()->subDay(),
|
||||
'current_period_end' => now()->addMonth(),
|
||||
]);
|
||||
|
||||
$this->actingAs($customer);
|
||||
|
||||
Livewire::test('customer.tokens')
|
||||
->set('tokenName', 'Ohne Nachweis')
|
||||
->set('selectedAbilities', ['press-releases:read'])
|
||||
->call('createToken')
|
||||
->assertHasErrors(['securityPassword']);
|
||||
|
||||
Livewire::test('customer.tokens')
|
||||
->set('tokenName', 'Falsches Passwort')
|
||||
->set('selectedAbilities', ['press-releases:read'])
|
||||
->set('securityPassword', 'falsch')
|
||||
->call('createToken')
|
||||
->assertHasErrors(['securityPassword']);
|
||||
|
||||
expect($customer->tokens()->count())->toBe(0);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,7 +2,10 @@
|
|||
|
||||
use App\Enums\Portal;
|
||||
use App\Enums\PressReleaseStatus;
|
||||
use App\Enums\SecurityChange;
|
||||
use App\Mail\EmailChangeConfirmation;
|
||||
use App\Mail\SecurityChangeNotice;
|
||||
use App\Mail\SecurityConfirmationCode;
|
||||
use App\Models\Category;
|
||||
use App\Models\Company;
|
||||
use App\Models\Contact;
|
||||
|
|
@ -10,6 +13,7 @@ use App\Models\LegacyInvoice;
|
|||
use App\Models\PressRelease;
|
||||
use App\Models\PressReleaseStatusLog;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\SecurityConfirmation;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
|
@ -215,6 +219,7 @@ test('two factor setup only becomes active after confirming an app code', functi
|
|||
$this->actingAs($customer);
|
||||
|
||||
$component = Livewire::test('customer.security')
|
||||
->set('securityPassword', 'password')
|
||||
->call('enableTwoFactorAuthentication');
|
||||
|
||||
$customer->refresh();
|
||||
|
|
@ -244,6 +249,7 @@ test('an invalid two factor confirmation code is rejected', function () {
|
|||
$this->actingAs($customer);
|
||||
|
||||
Livewire::test('customer.security')
|
||||
->set('securityPassword', 'password')
|
||||
->call('enableTwoFactorAuthentication')
|
||||
->set('twoFactorCode', '000000')
|
||||
->call('confirmTwoFactorAuthentication')
|
||||
|
|
@ -623,6 +629,7 @@ test('cancelling an unconfirmed two-factor setup needs no proof', function () {
|
|||
$this->actingAs($customer);
|
||||
|
||||
Livewire::test('customer.security')
|
||||
->set('securityPassword', 'password')
|
||||
->call('enableTwoFactorAuthentication')
|
||||
->call('disableTwoFactorAuthentication')
|
||||
->assertHasNoErrors();
|
||||
|
|
@ -672,7 +679,12 @@ test('changing the email asks for the password, or for the app code once two-fac
|
|||
expect($customer->fresh()->pending_email)->toBe('neu2@example.com');
|
||||
});
|
||||
|
||||
test('a passwordless magic-link account can still start an email change', function () {
|
||||
/*
|
||||
* Sicherheitsaudit 07.10.2026, S-06: Ein passwortloses Konto ohne 2FA kann die
|
||||
* Adresse weiterhin ändern, aber nur mit einem Code an die BISHERIGE Adresse —
|
||||
* eine fremde Session allein reicht nicht mehr.
|
||||
*/
|
||||
test('a passwordless magic-link account confirms an email change with a code sent to its current address', function () {
|
||||
/** @var TestCase $this */
|
||||
Mail::fake();
|
||||
|
||||
|
|
@ -685,7 +697,153 @@ test('a passwordless magic-link account can still start an email change', functi
|
|||
Livewire::test('customer.security')
|
||||
->set('email', 'magic-neu@example.com')
|
||||
->call('updateEmail')
|
||||
->assertHasErrors(['securityEmailCode']);
|
||||
|
||||
expect($customer->fresh()->pending_email)->toBeNull();
|
||||
|
||||
$component = Livewire::test('customer.security')
|
||||
->call('requestSecurityEmailCode')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$code = null;
|
||||
Mail::assertQueued(SecurityConfirmationCode::class, function (SecurityConfirmationCode $mail) use (&$code) {
|
||||
$code = $mail->code;
|
||||
|
||||
return $mail->hasTo('magic@example.com');
|
||||
});
|
||||
|
||||
$component
|
||||
->set('email', 'magic-neu@example.com')
|
||||
->set('securityEmailCode', $code === '000000' ? '111111' : '000000')
|
||||
->call('updateEmail')
|
||||
->assertHasErrors(['securityEmailCode']);
|
||||
|
||||
$component
|
||||
->set('securityEmailCode', $code)
|
||||
->call('updateEmail')
|
||||
->assertHasNoErrors();
|
||||
|
||||
expect($customer->fresh()->pending_email)->toBe('magic-neu@example.com');
|
||||
});
|
||||
|
||||
test('the email code is single use and burns after five wrong attempts', function () {
|
||||
/** @var TestCase $this */
|
||||
Mail::fake();
|
||||
|
||||
$customer = User::factory()->create(['is_active' => true, 'email' => 'magic-burn@example.com']);
|
||||
DB::table('users')->where('id', $customer->id)->update(['password' => '']);
|
||||
$customer = $customer->fresh();
|
||||
|
||||
$confirmation = app(SecurityConfirmation::class);
|
||||
$confirmation->sendEmailCode($customer);
|
||||
|
||||
$code = null;
|
||||
Mail::assertQueued(SecurityConfirmationCode::class, function (SecurityConfirmationCode $mail) use (&$code) {
|
||||
$code = $mail->code;
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
$wrong = $code === '000000' ? '111111' : '000000';
|
||||
|
||||
foreach (range(1, SecurityConfirmation::MAX_CODE_ATTEMPTS) as $attempt) {
|
||||
expect($confirmation->verifyEmailCode($customer, $wrong))->toBeFalse();
|
||||
}
|
||||
|
||||
expect($confirmation->verifyEmailCode($customer, $code))->toBeFalse();
|
||||
|
||||
$confirmation->sendEmailCode($customer);
|
||||
Mail::assertQueued(SecurityConfirmationCode::class, function (SecurityConfirmationCode $mail) use (&$code) {
|
||||
$code = $mail->code;
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
expect($confirmation->verifyEmailCode($customer, $code))->toBeTrue()
|
||||
->and($confirmation->verifyEmailCode($customer, $code))->toBeFalse();
|
||||
});
|
||||
|
||||
test('first-time two-factor setup needs a fresh identity proof', function () {
|
||||
/** @var TestCase $this */
|
||||
$customer = User::factory()->create(['is_active' => true]);
|
||||
$this->actingAs($customer);
|
||||
|
||||
Livewire::test('customer.security')
|
||||
->call('enableTwoFactorAuthentication')
|
||||
->assertHasErrors(['securityPassword']);
|
||||
|
||||
Livewire::test('customer.security')
|
||||
->set('securityPassword', 'falsch')
|
||||
->call('enableTwoFactorAuthentication')
|
||||
->assertHasErrors(['securityPassword']);
|
||||
|
||||
expect($customer->fresh()->two_factor_secret)->toBeNull();
|
||||
|
||||
// Nach erfolgreichem Nachweis gilt die Session eine Weile als bestätigt.
|
||||
Livewire::test('customer.security')
|
||||
->set('securityPassword', 'password')
|
||||
->call('enableTwoFactorAuthentication')
|
||||
->assertHasNoErrors()
|
||||
->call('disableTwoFactorAuthentication')
|
||||
->call('enableTwoFactorAuthentication')
|
||||
->assertHasNoErrors();
|
||||
|
||||
expect($customer->fresh()->two_factor_secret)->not->toBeNull();
|
||||
|
||||
$this->travel(SecurityConfirmation::FRESH_FOR_SECONDS + 60)->seconds();
|
||||
|
||||
Livewire::test('customer.security')
|
||||
->call('disableTwoFactorAuthentication')
|
||||
->call('enableTwoFactorAuthentication')
|
||||
->assertHasErrors(['securityPassword']);
|
||||
});
|
||||
|
||||
test('security changes are reported to the account address', function () {
|
||||
/** @var TestCase $this */
|
||||
Mail::fake();
|
||||
|
||||
$customer = User::factory()->create(['is_active' => true, 'email' => 'hinweis@example.com']);
|
||||
$this->actingAs($customer);
|
||||
|
||||
$component = Livewire::test('customer.security')
|
||||
->set('securityPassword', 'password')
|
||||
->call('enableTwoFactorAuthentication');
|
||||
|
||||
$otp = app(Google2FA::class)->getCurrentOtp(decrypt($customer->fresh()->two_factor_secret));
|
||||
$component->set('twoFactorCode', $otp)->call('confirmTwoFactorAuthentication')->assertHasNoErrors();
|
||||
|
||||
Mail::assertQueued(SecurityChangeNotice::class, fn (SecurityChangeNotice $mail) => $mail->change === SecurityChange::TwoFactorEnabled
|
||||
&& $mail->hasTo('hinweis@example.com'));
|
||||
|
||||
Livewire::test('customer.security')
|
||||
->set('current_password', 'password')
|
||||
->set('password', 'new-strong-password')
|
||||
->set('password_confirmation', 'new-strong-password')
|
||||
->call('updatePassword')
|
||||
->assertHasNoErrors();
|
||||
|
||||
Mail::assertQueued(SecurityChangeNotice::class, fn (SecurityChangeNotice $mail) => $mail->change === SecurityChange::PasswordChanged);
|
||||
});
|
||||
|
||||
test('a confirmed email change is reported to the previous address', function () {
|
||||
/** @var TestCase $this */
|
||||
Mail::fake();
|
||||
|
||||
$customer = User::factory()->create([
|
||||
'is_active' => true,
|
||||
'email' => 'vorher@example.com',
|
||||
'pending_email' => 'nachher@example.com',
|
||||
]);
|
||||
|
||||
$url = URL::temporarySignedRoute('email-change.confirm', now()->addHour(), [
|
||||
'user' => $customer->id,
|
||||
'hash' => sha1('nachher@example.com'),
|
||||
]);
|
||||
|
||||
$this->actingAs($customer)->get($url)->assertRedirect(route('me.security'));
|
||||
|
||||
expect($customer->fresh()->email)->toBe('nachher@example.com');
|
||||
Mail::assertQueued(SecurityChangeNotice::class, fn (SecurityChangeNotice $mail) => $mail->change === SecurityChange::EmailChanged
|
||||
&& $mail->hasTo('vorher@example.com')
|
||||
&& $mail->newEmail === 'nachher@example.com');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ export default defineConfig({
|
|||
"resources/js/app.js",
|
||||
"resources/js/portal-form-hooks.js",
|
||||
"resources/js/error-reporting.js",
|
||||
"resources/js/passkeys.js",
|
||||
],
|
||||
refresh: portalRefreshPaths,
|
||||
hotFile: PORTAL_HOT_FILE,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue