presseportale/app/Services/Import/UserImporter.php
Kevin Adametz 94cb209a9f WS-6: E-Mail-Verifizierung, Auth-Flow-Fixes & Legacy-Rollen-Sicherheitsfix
E-Mail-Verifizierung (Entscheidung 15.06.):
- User implementiert MustVerifyEmail; Registrierung legt inaktives, rollenloses
  Konto an und leitet auf die Danke-/Notice-Seite; Registered-Event versendet die
  Verifizierungsmail. Bestätigter Link aktiviert das Konto + vergibt customer-Rolle
  (ActivateUserAfterVerification). Backfill-Migration setzt email_verified_at für
  alle Bestands-User (sonst würde die verified-Middleware ~59k aktive Legacy-User
  aussperren). Seeder-User verifiziert.

Auth-Flow-Korrekturen:
- Magic-Link-Consume: rollensicherer Redirect ohne intended() (Customer landete
  sonst per stale intended=/dashboard im 403-Admin-Bereich).
- Guest-Redirect (bootstrap/app.php) rollen-/verifizierungsbewusst statt fix
  /dashboard – schließt die 403-Sackgasse auf /login und /register.
- Logout auf der Notice-Seite via echtes POST-Formular statt Livewire-Action
  (behebt 419 beim Session-Invalidate).
- Magic-Link-Anforderung über eigenes Modal mit separater E-Mail-Eingabe.
- Unverifizierte Login-Versuche landen auf der Notice-Seite.

Sicherheitsfix Legacy-Rollen:
- UserImporter mappte Alt-Gruppe 2 (Self-Publisher) auf editor (= Admin-Zugriff).
  Mapping auf customer korrigiert; Daten-Migration stuft die 65.950 fälschlichen
  Legacy-Editoren auf customer herab. Echte admin/api-only bleiben unberührt.

Tests: Registration, EmailVerification, Authentication (Guest-Redirect),
MagicLinkLogin (Modal/Redirect/Regression), Legacy-Import (Gruppen-Mapping).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-16 08:16:41 +00:00

271 lines
9.8 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Services\Import;
use App\Enums\Portal;
use App\Enums\RegistrationType;
use App\Models\LegacyImportMap;
use App\Models\Profile;
use App\Models\User;
use App\Services\Auth\UserRolePermissionSyncService;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class UserImporter
{
/**
* Legacy-Gruppen → Spatie-Rollen.
*
* Achtung: Die Alt-Gruppe 2 hieß dort „editor", meinte aber die normale
* Self-Publisher-Masse (Kunden, die ihre eigenen PMs einstellen). Im neuen
* Rollenmodell ist `editor` ein Redaktions-/Staff-Recht MIT Admin-Panel-
* Zugriff (canAccessAdmin). Legacy-Publisher gehören daher auf `customer`
* (nur „Mein Bereich"), nicht auf `editor`. `editor` wird ausschließlich
* manuell an echtes Personal vergeben.
*/
private const GROUP_ROLE_MAP = [
1 => 'admin',
2 => 'customer',
3 => 'api-only',
4 => 'customer',
];
private const DEFAULT_ROLE = 'customer';
private const CHUNK_SIZE = 500;
/** Registration-Type-Mapping aus Legacy-Wert */
private const REG_TYPE_MAP = [
'agency' => RegistrationType::Agency,
'company' => RegistrationType::Company,
'apiuser' => RegistrationType::ApiUser,
];
public function __construct(
private readonly UserRolePermissionSyncService $roleSync,
) {}
public function run(ImportContext $ctx): ImportResult
{
$result = new ImportResult;
$conn = $ctx->connection;
$portal = $ctx->portalEnum;
$legacyPortal = $ctx->legacyPortalValue();
// Lade alle Gruppen-Zuordnungen in Memory (klein genug)
$userGroups = DB::connection($conn)
->table('sf_guard_user_group')
->get()
->groupBy('user_id')
->map(fn ($rows) => $rows->pluck('group_id')->first());
DB::connection($conn)
->table('sf_guard_user')
->join('sf_guard_user_profile', 'sf_guard_user.id', '=', 'sf_guard_user_profile.user_id')
->select([
'sf_guard_user.id as legacy_id',
'sf_guard_user.username',
'sf_guard_user.is_active',
'sf_guard_user.is_super_admin',
'sf_guard_user.last_login',
'sf_guard_user.ip_address',
'sf_guard_user.created_at',
'sf_guard_user.updated_at',
'sf_guard_user_profile.email',
'sf_guard_user_profile.salutation_id',
'sf_guard_user_profile.title',
'sf_guard_user_profile.first_name',
'sf_guard_user_profile.last_name',
'sf_guard_user_profile.address',
'sf_guard_user_profile.country_id',
'sf_guard_user_profile.phone',
'sf_guard_user_profile.birthdate',
'sf_guard_user_profile.language',
'sf_guard_user_profile.backlink_url',
'sf_guard_user_profile.show_stats',
'sf_guard_user_profile.validation_date',
'sf_guard_user_profile.contract_date',
'sf_guard_user_profile.registration_type',
'sf_guard_user_profile.validate',
'sf_guard_user_profile.tax_id_number',
'sf_guard_user_profile.tax_exempt',
'sf_guard_user_profile.tax_exempt_reason',
'sf_guard_user_profile.disable_footer_code',
])
->orderBy('sf_guard_user.id')
->chunk(self::CHUNK_SIZE, function ($rows) use ($ctx, $result, $portal, $legacyPortal, $userGroups): void {
foreach ($rows as $row) {
try {
$this->importRow($row, $ctx, $result, $portal, $legacyPortal, $userGroups);
} catch (\Throwable $e) {
$result->addError("User legacy_id={$row->legacy_id}: {$e->getMessage()}");
}
}
});
return $result;
}
private function importRow(
object $row,
ImportContext $ctx,
ImportResult $result,
Portal $portal,
string $legacyPortal,
Collection $userGroups,
): void {
// E-Mail-Adresse ist Pflicht
$email = trim((string) $row->email);
if (blank($email) || ! filter_var($email, FILTER_VALIDATE_EMAIL)) {
$result->incrementSkipped();
return;
}
// Idempotenz-Check via legacy_import_map
$alreadyImported = LegacyImportMap::query()
->where('legacy_portal', $legacyPortal)
->where('legacy_table', 'sf_guard_user')
->where('legacy_id', $row->legacy_id)
->exists();
if ($alreadyImported && ! $ctx->force) {
$result->incrementSkipped();
return;
}
if ($ctx->dryRun) {
$result->incrementImported();
return;
}
$name = trim(($row->first_name ?? '').' '.($row->last_name ?? ''));
if (blank($name)) {
$name = $row->username;
}
$language = in_array($row->language, ['de', 'en']) ? $row->language : 'de';
$regType = self::REG_TYPE_MAP[$row->registration_type] ?? RegistrationType::ExistingLegacy;
$user = User::withoutTimestamps(function () use ($email, $name, $portal, $regType, $language, $row, $legacyPortal): User {
return User::query()->updateOrCreate(
['email' => $email],
[
'name' => $name,
'portal' => $portal->value,
'registration_type' => $regType->value,
'language' => $language,
'is_active' => (bool) $row->is_active,
'is_super_admin' => (bool) $row->is_super_admin,
'last_login_at' => $row->last_login ?: null,
'last_login_ip' => $row->ip_address ?: null,
'legacy_portal' => $legacyPortal,
'legacy_id' => $row->legacy_id,
'created_at' => $row->created_at ?? now(),
'updated_at' => $row->updated_at ?? $row->created_at ?? now(),
// Kein Passwort User erhält Go-Live-Reset-Mail (D-09)
]
);
});
// Rolle zuweisen
$groupId = $userGroups->get($row->legacy_id);
$roleName = self::GROUP_ROLE_MAP[$groupId] ?? self::DEFAULT_ROLE;
$this->roleSync->assignRoleAndSyncPermissions($user, $roleName);
Profile::query()->updateOrCreate(
['user_id' => $user->id],
[
'salutation_key' => $this->mapSalutation((int) ($row->salutation_id ?? 0)),
'title' => $this->cleanText($row->title ?? null, 80),
'first_name' => $this->cleanText($row->first_name ?? null, 80),
'last_name' => $this->cleanText($row->last_name ?? null, 80),
'phone' => $this->cleanText($row->phone ?? null, 40),
'address' => $this->cleanText($row->address ?? null, 1000),
'country_code' => $this->mapCountry((int) ($row->country_id ?? 0)),
'birthdate' => $this->validDateOrNull($row->birthdate ?? null),
'backlink_url' => $this->cleanText($row->backlink_url ?? null, 255),
'show_stats' => (bool) ($row->show_stats ?? false),
'validation_date' => $this->validDateOrNull($row->validation_date ?? null),
'contract_date' => $this->validDateOrNull($row->contract_date ?? null),
'validate_token' => $this->cleanText($row->validate ?? null, 64),
'tax_id_number' => $this->cleanText($row->tax_id_number ?? null, 255),
'tax_exempt' => (bool) ($row->tax_exempt ?? false),
'tax_exempt_reason' => $this->cleanText($row->tax_exempt_reason ?? null, 1000),
'disable_footer_code' => (bool) ($row->disable_footer_code ?? false),
]
);
// Import-Map eintragen
LegacyImportMap::query()->updateOrCreate(
[
'legacy_portal' => $legacyPortal,
'legacy_table' => 'sf_guard_user',
'legacy_id' => $row->legacy_id,
],
[
'target_table' => 'users',
'target_id' => $user->id,
'imported_at' => now(),
]
);
if ($alreadyImported) {
$result->incrementUpdated();
} else {
$result->incrementImported();
}
}
private function mapSalutation(int $salutationId): ?string
{
return match ($salutationId) {
1 => 'mr',
2 => 'mrs',
3 => 'none',
default => null,
};
}
private function mapCountry(int $countryId): ?string
{
return match ($countryId) {
177 => 'DE',
80 => 'AT',
196 => 'CH',
115 => 'LI',
117 => 'LU',
21 => 'BE',
2 => 'NL',
165 => 'FR',
30 => 'GB',
229 => 'US',
default => null,
};
}
private function cleanText(?string $value, int $maxLength): ?string
{
if (blank($value)) {
return null;
}
$clean = html_entity_decode((string) $value, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$clean = preg_replace('/[\x00-\x1F\x7F\xC2\xA0]/u', ' ', $clean) ?? $clean;
$clean = trim((string) $clean);
return blank($clean) ? null : mb_substr($clean, 0, $maxLength);
}
private function validDateOrNull(mixed $value): ?string
{
if (blank($value) || str_starts_with((string) $value, '0000-00-00')) {
return null;
}
return (string) $value;
}
}