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,145 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
/**
* Bestandsprüfung der `responsible`-Firmenzuordnungen (Audit F01/F10, Plan 1.4).
*
* Erklärbar ist eine Verantwortlichkeit, wenn die Konto-Adresse heute an der
* Firma selbst oder an einem ihrer Pressekontakte hinterlegt ist – genau die
* Grundlage, aus der der Login-Sync sie vergibt. Alles andere wird gelistet:
* manuell vergeben, aus dem Altbestand, oder nach einem Adresswechsel stehen
* geblieben. Der Befehl löscht nichts.
*
* `--classify` setzt bei den erklärbaren Zeilen `granted_via = auto`, damit
* der Login-Sync sie zurücknehmen kann, sobald die Grundlage wegfällt. Nicht
* erklärbare Zeilen bleiben `manual` und damit geschützt – die Entscheidung
* je Zeile trifft ein Mensch anhand der Liste.
*/
class AuditCompanyMemberships extends Command
{
protected $signature = 'company-memberships:audit
{--classify : Erklärbare responsible-Zuordnungen als automatisch (auto) markieren}
{--since=2026-07-20 : Nur Zuordnungen ab diesem Datum in der Tabelle zeigen (Go-Live); die Datei enthält alle}';
protected $description = 'Listet responsible-Firmenzuordnungen ohne aktuelle E-Mail-Grundlage und markiert erklärbare als automatisch vergeben.';
public function handle(): int
{
$total = DB::table('company_user')->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<int, \stdClass>
*/
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<string, array{company_id: int, user_id: int}>
*/
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;
}
}

View file

@ -214,7 +214,7 @@ class Company extends Model
public function users(): BelongsToMany
{
return $this->belongsToMany(User::class)
->withPivot('role')
->withPivot('role', 'granted_via')
->withTimestamps();
}

View file

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

View file

@ -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<int, int> $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']);
}
}

View file

@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Herkunft einer Firmenzuordnung (Audit F10, Plan 1.3).
*
* `granted_via` unterscheidet, ob der Pivot vom E-Mail-Sync (`auto`) oder von
* Hand bzw. aus dem Legacy-Import (`manual`) stammt. Nur automatische
* `responsible`-Rechte nimmt der Login-Sync wieder auf `member` zurück, wenn
* die E-Mail-Grundlage weggefallen ist. Bestehende Zeilen gelten als `manual`;
* `php artisan company-memberships:audit --classify` markiert die erklärbaren.
*
* Kleine Tabelle ohne Volltext-Index, ALGORITHM=INSTANT.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('company_user', function (Blueprint $table) {
if (! Schema::hasColumn('company_user', 'granted_via')) {
$table->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');
}
});
}
};

View file

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

View file

@ -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
</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
<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>
@ -417,13 +518,22 @@ new #[Layout('components.layouts.app'), Title('Konto-Sicherheit')] class extends
</div>
@endif
<div class="mt-5 pt-4 border-t border-[color:var(--color-bg-rule)] flex flex-wrap gap-2">
<flux:button wire:click="regenerateRecoveryCodes" variant="filled">
{{ __('Neue Wiederherstellungs-Codes erzeugen') }}
</flux:button>
<flux:button wire:click="disableTwoFactorAuthentication" variant="danger">
{{ __('Zwei-Faktor deaktivieren') }}
</flux:button>
<div class="mt-5 pt-4 border-t border-[color:var(--color-bg-rule)] space-y-3">
<p class="text-[12px] text-[color:var(--color-ink-3)] m-0">
{{ __('Zum Deaktivieren oder für neue Wiederherstellungs-Codes geben Sie einen aktuellen Code aus Ihrer Authenticator-App oder einen Wiederherstellungs-Code ein.') }}
</p>
<div class="max-w-[280px]">
<flux:input wire:model="securityCode" :label="__('Code aus der App oder Wiederherstellungs-Code')"
inputmode="numeric" autocomplete="one-time-code" placeholder="123456" />
</div>
<div class="flex flex-wrap gap-2">
<flux:button wire:click="regenerateRecoveryCodes" variant="filled">
{{ __('Neue Wiederherstellungs-Codes erzeugen') }}
</flux:button>
<flux:button wire:click="disableTwoFactorAuthentication" variant="danger">
{{ __('Zwei-Faktor deaktivieren') }}
</flux:button>
</div>
</div>
@endif
</div>

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