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:
parent
eeb1cde48d
commit
301bca3a90
10 changed files with 596 additions and 24 deletions
145
app/Console/Commands/AuditCompanyMemberships.php
Normal file
145
app/Console/Commands/AuditCompanyMemberships.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -214,7 +214,7 @@ class Company extends Model
|
||||||
public function users(): BelongsToMany
|
public function users(): BelongsToMany
|
||||||
{
|
{
|
||||||
return $this->belongsToMany(User::class)
|
return $this->belongsToMany(User::class)
|
||||||
->withPivot('role')
|
->withPivot('role', 'granted_via')
|
||||||
->withTimestamps();
|
->withTimestamps();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
use Illuminate\Notifications\Notifiable;
|
use Illuminate\Notifications\Notifiable;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Laravel\Cashier\Billable;
|
use Laravel\Cashier\Billable;
|
||||||
use Laravel\Fortify\TwoFactorAuthenticatable;
|
use Laravel\Fortify\TwoFactorAuthenticatable;
|
||||||
|
|
@ -373,7 +374,7 @@ class User extends Authenticatable implements MustVerifyEmail
|
||||||
public function companies(): BelongsToMany
|
public function companies(): BelongsToMany
|
||||||
{
|
{
|
||||||
return $this->belongsToMany(Company::class)
|
return $this->belongsToMany(Company::class)
|
||||||
->withPivot('role')
|
->withPivot('role', 'granted_via')
|
||||||
->withTimestamps();
|
->withTimestamps();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -601,10 +602,24 @@ class User extends Authenticatable implements MustVerifyEmail
|
||||||
*/
|
*/
|
||||||
public function syncCompaniesWithRoles(array $companyIds, array $roles): void
|
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 = [];
|
$payload = [];
|
||||||
|
|
||||||
foreach ($companyIds as $companyId) {
|
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);
|
$this->companies()->sync($payload);
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ use App\Models\Contact;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Scopes\PortalScope;
|
use App\Scopes\PortalScope;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Mail;
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -106,6 +107,11 @@ class ContactAccessService
|
||||||
* Konto, das seine E-Mail gerade auf eine fremde Firmen-Adresse gewechselt
|
* Konto, das seine E-Mail gerade auf eine fremde Firmen-Adresse gewechselt
|
||||||
* hat, ist unverifiziert und erhält nichts – der Login-Event feuert auch
|
* hat, ist unverifiziert und erhält nichts – der Login-Event feuert auch
|
||||||
* vor der Verifizierungs-Weiche. Deaktivierte Konten ebenfalls nicht.
|
* 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
|
public function syncMembershipsForEmail(User $user): void
|
||||||
{
|
{
|
||||||
|
|
@ -121,12 +127,31 @@ class ContactAccessService
|
||||||
|
|
||||||
$contacts = $this->contactsFor($email);
|
$contacts = $this->contactsFor($email);
|
||||||
$companies = $this->companiesFor($email);
|
$companies = $this->companiesFor($email);
|
||||||
|
$companyIds = $this->companyIdsFrom($contacts, $companies);
|
||||||
|
|
||||||
if ($contacts->isEmpty() && $companies->isEmpty()) {
|
$this->revokeStaleAutomaticGrants($user, $companyIds);
|
||||||
|
|
||||||
|
if ($companyIds->isEmpty()) {
|
||||||
return;
|
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();
|
$linked = $user->companies()->withoutGlobalScope(PortalScope::class)->whereKey($companyId)->first();
|
||||||
|
|
||||||
if ($linked === null) {
|
if ($linked === null) {
|
||||||
$user->companies()->attach($companyId, ['role' => 'responsible']);
|
$user->companies()->attach($companyId, ['role' => 'responsible', 'granted_via' => 'auto']);
|
||||||
} elseif ($linked->pivot->role === 'member') {
|
} elseif ($linked->pivot->role === 'member') {
|
||||||
// Nur den Read-only-Default hochstufen; owner/responsible bleiben.
|
// 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']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -696,14 +696,7 @@ new #[Layout('components.layouts.app'), Title('Benutzer bearbeiten')] class exte
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$companySyncPayload = [];
|
$user->syncCompaniesWithRoles($this->linkedCompanyIds, $this->companyRoles);
|
||||||
foreach ($this->linkedCompanyIds as $companyId) {
|
|
||||||
$companySyncPayload[(int) $companyId] = [
|
|
||||||
'role' => $this->companyRoles[(int) $companyId] ?? 'member',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$user->companies()->sync($companySyncPayload);
|
|
||||||
$user->contacts()->sync($this->linkedContactIds);
|
$user->contacts()->sync($this->linkedContactIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Illuminate\Support\Facades\Mail;
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
use Illuminate\Support\Facades\URL;
|
use Illuminate\Support\Facades\URL;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
|
|
@ -16,6 +17,7 @@ use Laravel\Fortify\Actions\DisableTwoFactorAuthentication;
|
||||||
use Laravel\Fortify\Actions\EnableTwoFactorAuthentication;
|
use Laravel\Fortify\Actions\EnableTwoFactorAuthentication;
|
||||||
use Laravel\Fortify\Actions\GenerateNewRecoveryCodes;
|
use Laravel\Fortify\Actions\GenerateNewRecoveryCodes;
|
||||||
use Laravel\Fortify\Features;
|
use Laravel\Fortify\Features;
|
||||||
|
use Laravel\Fortify\TwoFactorAuthenticationProvider;
|
||||||
use Livewire\Attributes\Layout;
|
use Livewire\Attributes\Layout;
|
||||||
use Livewire\Attributes\Locked;
|
use Livewire\Attributes\Locked;
|
||||||
use Livewire\Attributes\Title;
|
use Livewire\Attributes\Title;
|
||||||
|
|
@ -33,6 +35,16 @@ new #[Layout('components.layouts.app'), Title('Konto-Sicherheit')] class extends
|
||||||
|
|
||||||
public string $twoFactorCode = '';
|
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
|
* Recovery-Codes werden nur direkt nach Bestätigung bzw. Neu-Erzeugung
|
||||||
* angezeigt (einmalige Sichtbarkeit), nicht bei jedem Seitenaufruf.
|
* angezeigt (einmalige Sichtbarkeit), nicht bei jedem Seitenaufruf.
|
||||||
|
|
@ -89,6 +101,8 @@ new #[Layout('components.layouts.app'), Title('Konto-Sicherheit')] class extends
|
||||||
/** @var User $user */
|
/** @var User $user */
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
|
$this->assertSecurityProof($user);
|
||||||
|
|
||||||
if (strcasecmp($validated['email'], $user->email) === 0) {
|
if (strcasecmp($validated['email'], $user->email) === 0) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'email' => __('Das ist bereits Ihre aktuelle E-Mail-Adresse.'),
|
'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
|
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->reset('twoFactorCode');
|
||||||
$this->showRecoveryCodes = false;
|
$this->showRecoveryCodes = false;
|
||||||
|
|
||||||
|
|
@ -162,12 +185,84 @@ new #[Layout('components.layouts.app'), Title('Konto-Sicherheit')] class extends
|
||||||
|
|
||||||
public function regenerateRecoveryCodes(GenerateNewRecoveryCodes $generate): void
|
public function regenerateRecoveryCodes(GenerateNewRecoveryCodes $generate): void
|
||||||
{
|
{
|
||||||
$generate(auth()->user());
|
/** @var User $user */
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
$this->assertSecondFactor($user);
|
||||||
|
|
||||||
|
$generate($user);
|
||||||
$this->showRecoveryCodes = true;
|
$this->showRecoveryCodes = true;
|
||||||
|
|
||||||
session()->flash('security-status', __('Neue Wiederherstellungs-Codes erzeugt. Die alten Codes sind ab sofort ungültig.'));
|
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
|
public function with(): array
|
||||||
{
|
{
|
||||||
/** @var User $user */
|
/** @var User $user */
|
||||||
|
|
@ -339,6 +434,12 @@ new #[Layout('components.layouts.app'), Title('Konto-Sicherheit')] class extends
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
<flux:input wire:model="email" type="email" :label="__('Neue E-Mail-Adresse')" autocomplete="email" required />
|
<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">
|
<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.') }}
|
{{ __('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>
|
</p>
|
||||||
|
|
@ -417,13 +518,22 @@ new #[Layout('components.layouts.app'), Title('Konto-Sicherheit')] class extends
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<div class="mt-5 pt-4 border-t border-[color:var(--color-bg-rule)] flex flex-wrap gap-2">
|
<div class="mt-5 pt-4 border-t border-[color:var(--color-bg-rule)] space-y-3">
|
||||||
<flux:button wire:click="regenerateRecoveryCodes" variant="filled">
|
<p class="text-[12px] text-[color:var(--color-ink-3)] m-0">
|
||||||
{{ __('Neue Wiederherstellungs-Codes erzeugen') }}
|
{{ __('Zum Deaktivieren oder für neue Wiederherstellungs-Codes geben Sie einen aktuellen Code aus Ihrer Authenticator-App oder einen Wiederherstellungs-Code ein.') }}
|
||||||
</flux:button>
|
</p>
|
||||||
<flux:button wire:click="disableTwoFactorAuthentication" variant="danger">
|
<div class="max-w-[280px]">
|
||||||
{{ __('Zwei-Faktor deaktivieren') }}
|
<flux:input wire:model="securityCode" :label="__('Code aus der App oder Wiederherstellungs-Code')"
|
||||||
</flux:button>
|
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>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
53
tests/Feature/Auth/CompanyMembershipAuditTest.php
Normal file
53
tests/Feature/Auth/CompanyMembershipAuditTest.php
Normal 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);
|
||||||
|
});
|
||||||
|
|
@ -332,3 +332,63 @@ test('the membership sync ignores unverified and deactivated users regardless of
|
||||||
app(ContactAccessService::class)->syncMembershipsForEmail($inactive);
|
app(ContactAccessService::class)->syncMembershipsForEmail($inactive);
|
||||||
expect($inactive->fresh()->accessibleCompanyIds())->not->toContain($company->id);
|
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');
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ use App\Models\PressRelease;
|
||||||
use App\Models\PressReleaseStatusLog;
|
use App\Models\PressReleaseStatusLog;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Illuminate\Support\Facades\Mail;
|
use Illuminate\Support\Facades\Mail;
|
||||||
use Illuminate\Support\Facades\URL;
|
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')
|
LivewireVolt::test('customer.security')
|
||||||
->set('email', 'neu@example.com')
|
->set('email', 'neu@example.com')
|
||||||
|
->set('securityPassword', 'password')
|
||||||
->call('updateEmail')
|
->call('updateEmail')
|
||||||
->assertHasNoErrors();
|
->assertHasNoErrors();
|
||||||
|
|
||||||
|
|
@ -556,3 +558,134 @@ test('recovery codes cannot be revealed by mutating the component property (Audi
|
||||||
->set('showRecoveryCodes', true)
|
->set('showRecoveryCodes', true)
|
||||||
->assertDontSee('geheim-code-eins');
|
->assertDontSee('geheim-code-eins');
|
||||||
})->throws(CannotUpdateLockedPropertyException::class);
|
})->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');
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue