auth: automatische Firmenrechte mit Herkunft und Entzug, Nachweis vor Sicherheitsaktionen (Audit Block 1, Deploy C)

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 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-09-18 10:19:28 +00:00
parent a7f991f01e
commit 62202a4b13
10 changed files with 596 additions and 24 deletions

View file

@ -0,0 +1,53 @@
<?php
use App\Models\Company;
use App\Models\Contact;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
/**
* Bestandsprüfung der `responsible`-Zuordnungen (Audit F01/F10, Plan 1.4):
* Welche Verantwortlichkeiten lassen sich heute noch über eine hinterlegte
* E-Mail erklären, welche nicht – und `--classify` markiert die erklärbaren
* als automatisch vergeben, damit der Login-Sync sie später zurücknehmen kann.
*/
test('the audit lists unexplained responsible links and classifies explained ones on request', function () {
/** @var TestCase $this */
Storage::fake('local');
$viaCompany = User::factory()->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);
});

View file

@ -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');
});

View file

@ -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');
});