presseportale/app/Livewire/Concerns/ConfirmsIdentity.php
Kevin Adametz bae8347f6c 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>
2026-10-07 16:09:35 +00:00

142 lines
4.7 KiB
PHP

<?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;
}
}