From 301bca3a90e65064500b0656467ac7baefa98141 Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Fri, 18 Sep 2026 10:19:28 +0000 Subject: [PATCH] auth: automatische Firmenrechte mit Herkunft und Entzug, Nachweis vor Sicherheitsaktionen (Audit Block 1, Deploy C) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F10: company_user.granted_via unterscheidet automatisch vergebene Rechte (E-Mail-Sync) von manuellen und aus dem Altbestand. Beim Login senkt der Sync automatische responsible-Rechte ohne aktuellen E-Mail- Treffer auf member (E2) — Mitarbeiterwechsel nehmen den Schreibzugriff mit, Lesezugriff bleibt. Das Admin-Formular markiert nur neue oder geänderte Zeilen als manual, unveränderte behalten ihre Herkunft. Bestandsprüfung: php artisan company-memberships:audit listet responsible-Zuordnungen ohne E-Mail-Grundlage (Tabelle ab Go-Live, CSV vollständig) und markiert mit --classify die erklärbaren als auto. Auf Live nach der Migration einmal mit --classify laufen lassen; nicht erklärbare Zeilen bleiben manual und damit geschützt. F03 (E3): Zwei-Faktor deaktivieren, Wiederherstellungs-Codes erneuern und die E-Mail-Änderung verlangen einen aktuellen App-Code oder einen Wiederherstellungs-Code (der dabei verbraucht wird); ohne 2FA das Passwort. Reine Magic-Link-Konten ohne Passwort (der Altbestand) können die Adresse weiter ändern — die neue Adresse muss ihren Bestätigungslink ohnehin einlösen. Fünf Fehlversuche pro Minute. Co-Authored-By: Claude Fable 5.1 --- .../Commands/AuditCompanyMemberships.php | 145 ++++++++++++++++++ app/Models/Company.php | 2 +- app/Models/User.php | 19 ++- app/Services/Auth/ContactAccessService.php | 34 +++- ..._add_granted_via_to_company_user_table.php | 37 +++++ .../views/livewire/admin/users/edit.blade.php | 9 +- .../livewire/customer/security.blade.php | 128 ++++++++++++++-- .../Auth/CompanyMembershipAuditTest.php | 53 +++++++ tests/Feature/Auth/ContactAccessTest.php | 60 ++++++++ tests/Feature/CustomerProfileSecurityTest.php | 133 ++++++++++++++++ 10 files changed, 596 insertions(+), 24 deletions(-) create mode 100644 app/Console/Commands/AuditCompanyMemberships.php create mode 100644 database/migrations/2026_09_18_150000_add_granted_via_to_company_user_table.php create mode 100644 tests/Feature/Auth/CompanyMembershipAuditTest.php diff --git a/app/Console/Commands/AuditCompanyMemberships.php b/app/Console/Commands/AuditCompanyMemberships.php new file mode 100644 index 00000000..8433118b --- /dev/null +++ b/app/Console/Commands/AuditCompanyMemberships.php @@ -0,0 +1,145 @@ +where('role', 'responsible')->count(); + $explained = $this->explainedPairs(); + $unexplained = $this->responsibleRows() + ->reject(fn (object $row): bool => isset($explained[$row->company_id.':'.$row->user_id])) + ->values(); + + $this->info(sprintf('%d responsible-Zuordnungen, davon %d über eine hinterlegte E-Mail erklärbar, %d nicht.', $total, count($explained), $unexplained->count())); + + $since = (string) $this->option('since'); + $recent = $unexplained->filter(fn (object $row): bool => (string) $row->created_at >= $since); + + if ($recent->isNotEmpty()) { + $this->line(sprintf('Nicht erklärbar und seit %s entstanden (%d, max. 50 gezeigt):', $since, $recent->count())); + $this->table( + ['User', 'E-Mail', 'Firma', 'Firmenname', 'Herkunft', 'Angelegt'], + $recent->take(50)->map(fn (object $row): array => [ + $row->user_id, + $row->email, + $row->company_id, + mb_strimwidth((string) $row->company_name, 0, 40, '…'), + $row->granted_via, + substr((string) $row->created_at, 0, 16), + ])->all(), + ); + } + + if ($unexplained->isNotEmpty()) { + $file = sprintf('company-memberships-unexplained-%s.csv', now()->format('Y-m-d-His')); + $lines = $unexplained->map(fn (object $row): string => implode(';', [ + $row->user_id, $row->email, $row->company_id, + str_replace(';', ',', (string) $row->company_name), $row->granted_via, $row->created_at, + ])); + Storage::disk('local')->put($file, "user_id;email;company_id;company_name;granted_via;created_at\n".$lines->implode("\n")); + $this->line(sprintf('Vollständige Liste: storage/app/%s', $file)); + } + + if (! $this->option('classify')) { + $this->info('Keine Änderung geschrieben. Mit --classify werden die erklärbaren Zeilen als auto markiert.'); + + return self::SUCCESS; + } + + $marked = 0; + + foreach (collect($explained)->chunk(500) as $chunk) { + foreach ($chunk as $pair) { + $marked += DB::table('company_user') + ->where('company_id', $pair['company_id']) + ->where('user_id', $pair['user_id']) + ->where('role', 'responsible') + ->where('granted_via', '!=', 'auto') + ->update(['granted_via' => 'auto']); + } + } + + $this->info(sprintf('%d Zuordnungen als automatisch (auto) markiert.', $marked)); + + return self::SUCCESS; + } + + /** + * Alle responsible-Zeilen mit Konto-Adresse und Firmenname. + * + * @return Collection + */ + private function responsibleRows(): Collection + { + return DB::table('company_user') + ->join('users', 'users.id', '=', 'company_user.user_id') + ->join('companies', 'companies.id', '=', 'company_user.company_id') + ->where('company_user.role', 'responsible') + ->orderBy('company_user.created_at') + ->get([ + 'company_user.company_id', 'company_user.user_id', 'company_user.granted_via', 'company_user.created_at', + 'users.email', 'companies.name as company_name', + ]); + } + + /** + * (company_id, user_id)-Paare, die über Firmen- oder Kontakt-Adresse + * erklärbar sind. Einmaliger Lauf, daher LOWER() beidseitig vertretbar. + * + * @return array + */ + private function explainedPairs(): array + { + $viaCompany = DB::table('company_user') + ->join('users', 'users.id', '=', 'company_user.user_id') + ->join('companies', 'companies.id', '=', 'company_user.company_id') + ->where('company_user.role', 'responsible') + ->whereRaw('LOWER(companies.email) = LOWER(users.email)'); + + $viaContact = DB::table('company_user') + ->join('users', 'users.id', '=', 'company_user.user_id') + ->join('contacts', function ($join): void { + $join->on('contacts.company_id', '=', 'company_user.company_id') + ->whereRaw('LOWER(contacts.email) = LOWER(users.email)') + ->whereNull('contacts.deleted_at'); + }) + ->where('company_user.role', 'responsible'); + + $pairs = []; + + foreach ([$viaCompany, $viaContact] as $query) { + foreach ($query->distinct()->get(['company_user.company_id', 'company_user.user_id']) as $row) { + $pairs[$row->company_id.':'.$row->user_id] = ['company_id' => (int) $row->company_id, 'user_id' => (int) $row->user_id]; + } + } + + return $pairs; + } +} diff --git a/app/Models/Company.php b/app/Models/Company.php index 1f837f9a..1dcd1af4 100644 --- a/app/Models/Company.php +++ b/app/Models/Company.php @@ -214,7 +214,7 @@ class Company extends Model public function users(): BelongsToMany { return $this->belongsToMany(User::class) - ->withPivot('role') + ->withPivot('role', 'granted_via') ->withTimestamps(); } diff --git a/app/Models/User.php b/app/Models/User.php index b9addd2e..d721f89c 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -15,6 +15,7 @@ use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; use Laravel\Cashier\Billable; use Laravel\Fortify\TwoFactorAuthenticatable; @@ -373,7 +374,7 @@ class User extends Authenticatable implements MustVerifyEmail public function companies(): BelongsToMany { return $this->belongsToMany(Company::class) - ->withPivot('role') + ->withPivot('role', 'granted_via') ->withTimestamps(); } @@ -601,10 +602,24 @@ class User extends Authenticatable implements MustVerifyEmail */ public function syncCompaniesWithRoles(array $companyIds, array $roles): void { + // Herkunft (Audit F10): Was der Admin neu anlegt oder in der Rolle + // ändert, ist `manual` und bleibt vom automatischen Entzug unberührt. + // Unveränderte Zeilen behalten ihre Herkunft – sonst würde jedes + // Speichern des Formulars alle automatischen Rechte zementieren. + $current = DB::table('company_user') + ->where('user_id', $this->getKey()) + ->pluck('role', 'company_id') + ->mapWithKeys(fn (string $role, int|string $companyId): array => [(int) $companyId => $role]); + $payload = []; foreach ($companyIds as $companyId) { - $payload[$companyId] = ['role' => $roles[$companyId] ?? 'member']; + $role = $roles[$companyId] ?? 'member'; + $payload[$companyId] = ['role' => $role]; + + if (! isset($current[(int) $companyId]) || $current[(int) $companyId] !== $role) { + $payload[$companyId]['granted_via'] = 'manual'; + } } $this->companies()->sync($payload); diff --git a/app/Services/Auth/ContactAccessService.php b/app/Services/Auth/ContactAccessService.php index e9f566a1..2761ebbf 100644 --- a/app/Services/Auth/ContactAccessService.php +++ b/app/Services/Auth/ContactAccessService.php @@ -9,6 +9,7 @@ use App\Models\Contact; use App\Models\User; use App\Scopes\PortalScope; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Mail; /** @@ -106,6 +107,11 @@ class ContactAccessService * Konto, das seine E-Mail gerade auf eine fremde Firmen-Adresse gewechselt * hat, ist unverifiziert und erhält nichts – der Login-Event feuert auch * vor der Verifizierungs-Weiche. Deaktivierte Konten ebenfalls nicht. + * + * Gegenrichtung (Audit F10, E2): automatisch vergebene `responsible`-Rechte, + * deren E-Mail-Grundlage weggefallen ist (Adresse an Firma oder Kontakt + * getauscht, Kontakt gelöscht), fallen auf `member` zurück. Manuell oder + * aus dem Altbestand vergebene Rechte (`granted_via = manual`) bleiben. */ public function syncMembershipsForEmail(User $user): void { @@ -121,12 +127,31 @@ class ContactAccessService $contacts = $this->contactsFor($email); $companies = $this->companiesFor($email); + $companyIds = $this->companyIdsFrom($contacts, $companies); - if ($contacts->isEmpty() && $companies->isEmpty()) { + $this->revokeStaleAutomaticGrants($user, $companyIds); + + if ($companyIds->isEmpty()) { return; } - $this->linkMemberships($user, $this->companyIdsFrom($contacts, $companies), $contacts); + $this->linkMemberships($user, $companyIds, $contacts); + } + + /** + * Senkt automatisch vergebene `responsible`-Pivots ohne aktuellen + * E-Mail-Treffer auf `member`. Lesezugriff bleibt, Schreibzugriff geht. + * + * @param Collection $currentCompanyIds + */ + private function revokeStaleAutomaticGrants(User $user, Collection $currentCompanyIds): void + { + DB::table('company_user') + ->where('user_id', $user->id) + ->where('role', 'responsible') + ->where('granted_via', 'auto') + ->whereNotIn('company_id', $currentCompanyIds->all()) + ->update(['role' => 'member', 'updated_at' => now()]); } /** @@ -256,10 +281,11 @@ class ContactAccessService $linked = $user->companies()->withoutGlobalScope(PortalScope::class)->whereKey($companyId)->first(); if ($linked === null) { - $user->companies()->attach($companyId, ['role' => 'responsible']); + $user->companies()->attach($companyId, ['role' => 'responsible', 'granted_via' => 'auto']); } elseif ($linked->pivot->role === 'member') { // Nur den Read-only-Default hochstufen; owner/responsible bleiben. - $user->companies()->updateExistingPivot($companyId, ['role' => 'responsible']); + // Die Hochstufung ist automatisch und damit wieder rücknehmbar. + $user->companies()->updateExistingPivot($companyId, ['role' => 'responsible', 'granted_via' => 'auto']); } } diff --git a/database/migrations/2026_09_18_150000_add_granted_via_to_company_user_table.php b/database/migrations/2026_09_18_150000_add_granted_via_to_company_user_table.php new file mode 100644 index 00000000..ca5f3e46 --- /dev/null +++ b/database/migrations/2026_09_18_150000_add_granted_via_to_company_user_table.php @@ -0,0 +1,37 @@ +string('granted_via', 16)->default('manual')->after('role'); + } + }); + } + + public function down(): void + { + Schema::table('company_user', function (Blueprint $table) { + if (Schema::hasColumn('company_user', 'granted_via')) { + $table->dropColumn('granted_via'); + } + }); + } +}; diff --git a/resources/views/livewire/admin/users/edit.blade.php b/resources/views/livewire/admin/users/edit.blade.php index 3602f536..8d6f6965 100644 --- a/resources/views/livewire/admin/users/edit.blade.php +++ b/resources/views/livewire/admin/users/edit.blade.php @@ -696,14 +696,7 @@ new #[Layout('components.layouts.app'), Title('Benutzer bearbeiten')] class exte return; } - $companySyncPayload = []; - foreach ($this->linkedCompanyIds as $companyId) { - $companySyncPayload[(int) $companyId] = [ - 'role' => $this->companyRoles[(int) $companyId] ?? 'member', - ]; - } - - $user->companies()->sync($companySyncPayload); + $user->syncCompaniesWithRoles($this->linkedCompanyIds, $this->companyRoles); $user->contacts()->sync($this->linkedContactIds); } diff --git a/resources/views/livewire/customer/security.blade.php b/resources/views/livewire/customer/security.blade.php index a9d8747a..c5899daf 100644 --- a/resources/views/livewire/customer/security.blade.php +++ b/resources/views/livewire/customer/security.blade.php @@ -6,6 +6,7 @@ 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; @@ -16,6 +17,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 Livewire\Attributes\Layout; use Livewire\Attributes\Locked; use Livewire\Attributes\Title; @@ -33,6 +35,16 @@ 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 = ''; + /** * Recovery-Codes werden nur direkt nach Bestätigung bzw. Neu-Erzeugung * angezeigt (einmalige Sichtbarkeit), nicht bei jedem Seitenaufruf. @@ -89,6 +101,8 @@ new #[Layout('components.layouts.app'), Title('Konto-Sicherheit')] class extends /** @var User $user */ $user = auth()->user(); + $this->assertSecurityProof($user); + if (strcasecmp($validated['email'], $user->email) === 0) { throw ValidationException::withMessages([ 'email' => __('Das ist bereits Ihre aktuelle E-Mail-Adresse.'), @@ -153,7 +167,16 @@ new #[Layout('components.layouts.app'), Title('Konto-Sicherheit')] class extends public function disableTwoFactorAuthentication(DisableTwoFactorAuthentication $disable): void { - $disable(auth()->user()); + /** @var User $user */ + $user = auth()->user(); + + // Eine noch unbestätigte Einrichtung abzubrechen braucht keinen + // Nachweis – aktiv ist die 2FA erst nach dem bestätigten App-Code. + if ($user->hasEnabledTwoFactorAuthentication()) { + $this->assertSecondFactor($user); + } + + $disable($user); $this->reset('twoFactorCode'); $this->showRecoveryCodes = false; @@ -162,12 +185,84 @@ new #[Layout('components.layouts.app'), Title('Konto-Sicherheit')] class extends public function regenerateRecoveryCodes(GenerateNewRecoveryCodes $generate): void { - $generate(auth()->user()); + /** @var User $user */ + $user = auth()->user(); + + $this->assertSecondFactor($user); + + $generate($user); $this->showRecoveryCodes = true; session()->flash('security-status', __('Neue Wiederherstellungs-Codes erzeugt. Die alten Codes sind ab sofort ungültig.')); } + /** + * 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). + */ + private function assertSecurityProof(User $user): void + { + if ($user->hasEnabledTwoFactorAuthentication()) { + $this->assertSecondFactor($user); + + return; + } + + if ((string) $user->password === '') { + return; + } + + try { + $this->validate(['securityPassword' => ['required', 'string', 'current_password']]); + } finally { + $this->reset('securityPassword'); + } + } + + /** + * 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 + { + $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.'), + ]); + } + public function with(): array { /** @var User $user */ @@ -339,6 +434,12 @@ new #[Layout('components.layouts.app'), Title('Konto-Sicherheit')] class extends @endif + @if ($twoFactorConfirmed) + + @elseif ((string) $user->password !== '') + + @endif

{{ __('Sie erhalten einen Bestätigungslink an die neue Adresse. Erst nach dem Klick wird die Änderung wirksam — Ihre bisherige Adresse bleibt bis dahin aktiv.') }}

@@ -417,13 +518,22 @@ new #[Layout('components.layouts.app'), Title('Konto-Sicherheit')] class extends @endif -
- - {{ __('Neue Wiederherstellungs-Codes erzeugen') }} - - - {{ __('Zwei-Faktor deaktivieren') }} - +
+

+ {{ __('Zum Deaktivieren oder für neue Wiederherstellungs-Codes geben Sie einen aktuellen Code aus Ihrer Authenticator-App oder einen Wiederherstellungs-Code ein.') }} +

+
+ +
+
+ + {{ __('Neue Wiederherstellungs-Codes erzeugen') }} + + + {{ __('Zwei-Faktor deaktivieren') }} + +
@endif
diff --git a/tests/Feature/Auth/CompanyMembershipAuditTest.php b/tests/Feature/Auth/CompanyMembershipAuditTest.php new file mode 100644 index 00000000..a4d07ccf --- /dev/null +++ b/tests/Feature/Auth/CompanyMembershipAuditTest.php @@ -0,0 +1,53 @@ +create(['email' => 'firma@example.test']); + $viaContact = User::factory()->create(['email' => 'kontakt@example.test']); + $orphan = User::factory()->create(['email' => 'niemand@example.test']); + + $companyA = Company::factory()->presseecho()->create(['email' => 'firma@example.test', 'name' => 'Erklärbar AG']); + $companyB = Company::factory()->presseecho()->create(['email' => 'andere@example.test', 'name' => 'Kontakt GmbH']); + Contact::factory()->for($companyB)->create(['email' => 'Kontakt@Example.test', 'portal' => $companyB->portal->value]); + $companyC = Company::factory()->presseecho()->create(['email' => 'dritte@example.test', 'name' => 'Verwaist KG']); + + $viaCompany->companies()->attach($companyA->id, ['role' => 'responsible']); + $viaContact->companies()->attach($companyB->id, ['role' => 'responsible']); + $orphan->companies()->attach($companyC->id, ['role' => 'responsible']); + $orphan->companies()->attach($companyA->id, ['role' => 'owner']); + + $this->artisan('company-memberships:audit', ['--since' => '2000-01-01']) + ->expectsOutputToContain('Verwaist KG') + ->doesntExpectOutputToContain('Erklärbar AG') + ->doesntExpectOutputToContain('Kontakt GmbH') + ->assertSuccessful(); + + // Ohne --classify bleibt alles manual. + expect(DB::table('company_user')->where('granted_via', 'auto')->count())->toBe(0); + + $this->artisan('company-memberships:audit', ['--classify' => true, '--since' => '2000-01-01'])->assertSuccessful(); + + $via = fn (int $userId, int $companyId): string => DB::table('company_user')->where('user_id', $userId)->where('company_id', $companyId)->value('granted_via'); + + expect($via($viaCompany->id, $companyA->id))->toBe('auto') + ->and($via($viaContact->id, $companyB->id))->toBe('auto') + ->and($via($orphan->id, $companyC->id))->toBe('manual') + ->and($via($orphan->id, $companyA->id))->toBe('manual'); + + expect(collect(Storage::disk('local')->files())->filter(fn (string $f) => str_starts_with($f, 'company-memberships-unexplained-')))->toHaveCount(1); +}); diff --git a/tests/Feature/Auth/ContactAccessTest.php b/tests/Feature/Auth/ContactAccessTest.php index 39edfc66..c3df38b9 100644 --- a/tests/Feature/Auth/ContactAccessTest.php +++ b/tests/Feature/Auth/ContactAccessTest.php @@ -332,3 +332,63 @@ test('the membership sync ignores unverified and deactivated users regardless of app(ContactAccessService::class)->syncMembershipsForEmail($inactive); expect($inactive->fresh()->accessibleCompanyIds())->not->toContain($company->id); }); + +test('an auto-granted responsible role is lowered to member on the next login once the email basis is gone (Audit F10)', function () { + /** @var TestCase $this */ + $user = User::factory()->create(['email' => 'wechsel@example.test', 'is_active' => true]); + $user->assignRole('customer'); + + $company = Company::factory()->presseecho()->create(['email' => 'wechsel@example.test']); + + Volt::test('auth.login')->set('email', 'wechsel@example.test')->set('password', 'password')->call('login')->assertHasNoErrors(); + + $pivot = $user->fresh()->companies()->withoutGlobalScopes()->whereKey($company->id)->first()->pivot; + expect($pivot->role)->toBe('responsible') + ->and($pivot->granted_via)->toBe('auto') + ->and($user->fresh()->can('update', $company))->toBeTrue(); + + // Mitarbeiterwechsel: die Firma trägt jetzt eine andere Adresse. + $company->forceFill(['email' => 'nachfolger@example.test'])->save(); + auth()->logout(); + + Volt::test('auth.login')->set('email', 'wechsel@example.test')->set('password', 'password')->call('login')->assertHasNoErrors(); + + $fresh = User::query()->findOrFail($user->id); + $pivot = $fresh->companies()->withoutGlobalScopes()->whereKey($company->id)->first()->pivot; + expect($pivot->role)->toBe('member') + ->and($fresh->can('update', $company))->toBeFalse() + ->and($fresh->can('view', $company))->toBeTrue(); +}); + +test('a manually granted responsible role survives the login sync without an email match (Audit F10)', function () { + /** @var TestCase $this */ + $user = User::factory()->create(['email' => 'manuell@example.test', 'is_active' => true]); + $user->assignRole('customer'); + + $company = Company::factory()->presseecho()->create(['email' => 'andere@example.test']); + $user->syncCompaniesWithRoles([$company->id], [$company->id => 'responsible']); + + expect($user->companies()->withoutGlobalScopes()->whereKey($company->id)->first()->pivot->granted_via)->toBe('manual'); + + Volt::test('auth.login')->set('email', 'manuell@example.test')->set('password', 'password')->call('login')->assertHasNoErrors(); + + expect(User::query()->findOrFail($user->id)->can('update', $company))->toBeTrue(); +}); + +test('the admin form keeps the auto origin of untouched rows and marks changed roles as manual (Audit F10)', function () { + /** @var TestCase $this */ + $user = User::factory()->create(['email' => 'form@example.test', 'is_active' => true]); + $auto = Company::factory()->presseecho()->create(['email' => 'form@example.test']); + $other = Company::factory()->presseecho()->create(); + + app(ContactAccessService::class)->syncMembershipsForEmail($user); + + // Unverändert speichern → Herkunft bleibt auto; Rolle ändern → manual. + $user->syncCompaniesWithRoles([$auto->id, $other->id], [$auto->id => 'responsible', $other->id => 'member']); + $pivots = $user->companies()->withoutGlobalScopes()->get()->keyBy('id'); + expect($pivots[$auto->id]->pivot->granted_via)->toBe('auto') + ->and($pivots[$other->id]->pivot->granted_via)->toBe('manual'); + + $user->syncCompaniesWithRoles([$auto->id], [$auto->id => 'owner']); + expect($user->companies()->withoutGlobalScopes()->whereKey($auto->id)->first()->pivot->granted_via)->toBe('manual'); +}); diff --git a/tests/Feature/CustomerProfileSecurityTest.php b/tests/Feature/CustomerProfileSecurityTest.php index 9e46b6e2..50cacd1a 100644 --- a/tests/Feature/CustomerProfileSecurityTest.php +++ b/tests/Feature/CustomerProfileSecurityTest.php @@ -11,6 +11,7 @@ use App\Models\PressRelease; use App\Models\PressReleaseStatusLog; use App\Models\User; use Database\Seeders\RolesAndPermissionsSeeder; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\URL; @@ -282,6 +283,7 @@ test('changing the email keeps the old address active and mails a confirm link t LivewireVolt::test('customer.security') ->set('email', 'neu@example.com') + ->set('securityPassword', 'password') ->call('updateEmail') ->assertHasNoErrors(); @@ -556,3 +558,134 @@ test('recovery codes cannot be revealed by mutating the component property (Audi ->set('showRecoveryCodes', true) ->assertDontSee('geheim-code-eins'); })->throws(CannotUpdateLockedPropertyException::class); + +test('disabling two-factor requires a current app code (Audit F03)', function () { + /** @var TestCase $this */ + $customer = User::factory()->create(['is_active' => true]); + $secret = app(Google2FA::class)->generateSecretKey(); + $customer->forceFill([ + 'two_factor_secret' => encrypt($secret), + 'two_factor_recovery_codes' => encrypt(json_encode(['geheim-code-eins', 'geheim-code-zwei'])), + 'two_factor_confirmed_at' => now(), + ])->save(); + + $this->actingAs($customer); + + LivewireVolt::test('customer.security') + ->call('disableTwoFactorAuthentication') + ->assertHasErrors(['securityCode']); + + LivewireVolt::test('customer.security') + ->set('securityCode', '000000') + ->call('disableTwoFactorAuthentication') + ->assertHasErrors(['securityCode']); + + expect($customer->fresh()->hasEnabledTwoFactorAuthentication())->toBeTrue(); + + LivewireVolt::test('customer.security') + ->set('securityCode', app(Google2FA::class)->getCurrentOtp($secret)) + ->call('disableTwoFactorAuthentication') + ->assertHasNoErrors(); + + expect($customer->fresh()->hasEnabledTwoFactorAuthentication())->toBeFalse(); +}); + +test('regenerating recovery codes accepts a recovery code as proof and consumes it (Audit F03)', function () { + /** @var TestCase $this */ + $customer = User::factory()->create(['is_active' => true]); + $customer->forceFill([ + 'two_factor_secret' => encrypt(app(Google2FA::class)->generateSecretKey()), + 'two_factor_recovery_codes' => encrypt(json_encode(['geheim-code-eins', 'geheim-code-zwei'])), + 'two_factor_confirmed_at' => now(), + ])->save(); + + $this->actingAs($customer); + + LivewireVolt::test('customer.security') + ->call('regenerateRecoveryCodes') + ->assertHasErrors(['securityCode']); + + expect($customer->fresh()->recoveryCodes())->toContain('geheim-code-eins'); + + LivewireVolt::test('customer.security') + ->set('securityCode', 'geheim-code-eins') + ->call('regenerateRecoveryCodes') + ->assertHasNoErrors() + ->assertSee('Wiederherstellungs-Codes'); + + expect($customer->fresh()->recoveryCodes())->not->toContain('geheim-code-eins') + ->and($customer->fresh()->recoveryCodes())->not->toContain('geheim-code-zwei'); +}); + +test('cancelling an unconfirmed two-factor setup needs no proof', function () { + /** @var TestCase $this */ + $customer = User::factory()->create(['is_active' => true]); + $this->actingAs($customer); + + LivewireVolt::test('customer.security') + ->call('enableTwoFactorAuthentication') + ->call('disableTwoFactorAuthentication') + ->assertHasNoErrors(); + + expect($customer->fresh()->two_factor_secret)->toBeNull(); +}); + +test('changing the email asks for the password, or for the app code once two-factor is active (Audit F03)', function () { + /** @var TestCase $this */ + Mail::fake(); + + $customer = User::factory()->create(['is_active' => true, 'email' => 'alt2@example.com']); + $this->actingAs($customer); + + LivewireVolt::test('customer.security') + ->set('email', 'neu2@example.com') + ->call('updateEmail') + ->assertHasErrors(['securityPassword']); + + LivewireVolt::test('customer.security') + ->set('email', 'neu2@example.com') + ->set('securityPassword', 'falsch') + ->call('updateEmail') + ->assertHasErrors(['securityPassword']); + + expect($customer->fresh()->pending_email)->toBeNull(); + + $secret = app(Google2FA::class)->generateSecretKey(); + $customer->forceFill([ + 'two_factor_secret' => encrypt($secret), + 'two_factor_recovery_codes' => encrypt(json_encode(['r-eins'])), + 'two_factor_confirmed_at' => now(), + ])->save(); + + LivewireVolt::test('customer.security') + ->set('email', 'neu2@example.com') + ->set('securityPassword', 'password') + ->call('updateEmail') + ->assertHasErrors(['securityCode']); + + LivewireVolt::test('customer.security') + ->set('email', 'neu2@example.com') + ->set('securityCode', app(Google2FA::class)->getCurrentOtp($secret)) + ->call('updateEmail') + ->assertHasNoErrors(); + + expect($customer->fresh()->pending_email)->toBe('neu2@example.com'); +}); + +test('a passwordless magic-link account can still start an email change', function () { + /** @var TestCase $this */ + Mail::fake(); + + $customer = User::factory()->create(['is_active' => true, 'email' => 'magic@example.com']); + // Wie im Altbestand (66.000 Konten): leeres Passwortfeld, kein Hash. Der + // `hashed`-Cast würde auch '' hashen, daher direkt in die Tabelle. + DB::table('users')->where('id', $customer->id)->update(['password' => '']); + $this->actingAs($customer->fresh()); + + LivewireVolt::test('customer.security') + ->set('email', 'magic-neu@example.com') + ->call('updateEmail') + ->assertHasNoErrors(); + + expect($customer->fresh()->pending_email)->toBe('magic-neu@example.com'); +});