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>
142 lines
4.7 KiB
PHP
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;
|
|
}
|
|
}
|