From 6527f3d64f518f3f041da67787e460f9993af0a4 Mon Sep 17 00:00:00 2001 From: Angel de la Torre Date: Fri, 18 Jul 2025 10:49:46 -0700 Subject: [PATCH] feat: add migration tool for external auth users If a user created via local auth has the same email address as an iFixit user, then they will be unable to login to the site with their iFixit account. We can't really delete the users from the DB if they have already created groups and events. As such, this migration tool will allow us to manually sync/migrate the users with their iFixit counterparts. --- .../Commands/MigrateUserToExternal.php | 229 ++++++++++++++++++ app/Services/Auth/iFixitAuthService.php | 34 +++ 2 files changed, 263 insertions(+) create mode 100644 app/Console/Commands/MigrateUserToExternal.php diff --git a/app/Console/Commands/MigrateUserToExternal.php b/app/Console/Commands/MigrateUserToExternal.php new file mode 100644 index 0000000000..fa335d732f --- /dev/null +++ b/app/Console/Commands/MigrateUserToExternal.php @@ -0,0 +1,229 @@ +ifixitService = $ifixitService; + } + + /** + * Get the console command description. + */ + public function getDescription(): string + { + return $this->description . "\n\n" . + "This command safely migrates a local user to link them with their iFixit account.\n" . + "It requires both the local user ID and the external iFixit user ID to prevent\n" . + "accidental account takeovers.\n\n" . + "Examples:\n" . + " php artisan user:migrate-to-external 123 456789\n" . + " php artisan user:migrate-to-external 123 456789 --dry-run\n" . + " php artisan user:migrate-to-external 123 456789 --force\n\n" . + "Options:\n" . + " --dry-run Preview changes without applying them\n" . + " --force Force migration even if user already has external_user_id"; + } + + /** + * Execute the console command. + */ + public function handle(): int + { + $localUserId = $this->argument('local_user_id'); + $externalUserId = $this->argument('external_user_id'); + $dryRun = $this->option('dry-run'); + $force = $this->option('force'); + + $this->info("Starting user migration for local user ID: {$localUserId} -> external user ID: {$externalUserId}"); + + // Find the local user + $localUser = User::find($localUserId); + + if (!$localUser) { + $this->error("Local user with ID '{$localUserId}' not found."); + return 1; + } + + // Check if user already has external_user_id + if (!$force && $localUser->external_user_id) { + $this->error("User already has external_user_id: {$localUser->external_user_id}. Use --force to override."); + return 1; + } + + $this->info("Found local user: {$localUser->name} (ID: {$localUser->id}, Email: {$localUser->email})"); + + // Get external user data from iFixit + $externalUserData = $this->ifixitService->getUserById($externalUserId); + + if (!$externalUserData) { + $this->error("Could not find iFixit user with ID '{$externalUserId}'."); + return 1; + } + + // Validate that the fetched user ID matches the requested external user ID + if ($externalUserData['userid'] != $externalUserId) { + $this->error("External user ID mismatch. Expected: {$externalUserId}, Got: {$externalUserData['userid']}"); + return 1; + } + + $this->info("Found iFixit user: {$externalUserData['username']} (ID: {$externalUserData['userid']})"); + + // Display what will be updated + $this->displayMigrationPreview($localUser, $externalUserData); + + if ($dryRun) { + $this->info("Dry run mode - no changes made."); + return 0; + } + + // Confirm the migration + if (!$force && !$this->confirm('Do you want to proceed with the migration?')) { + $this->info("Migration cancelled."); + return 0; + } + + // Perform the migration + return $this->performMigration($localUser, $externalUserData); + } + + /** + * Display what will be changed during migration + */ + private function displayMigrationPreview(User $localUser, array $externalUserData): void + { + $this->info("\n=== Migration Preview ==="); + + $changes = []; + + // Name + if ($localUser->name !== $externalUserData['username']) { + $changes[] = "Name: '{$localUser->name}' → '{$externalUserData['username']}'"; + } + + // External user ID + if ($localUser->external_user_id !== $externalUserData['userid']) { + $changes[] = "External User ID: " . ($localUser->external_user_id ?: 'null') . " → '{$externalUserData['userid']}'"; + } + + // External username + $newExternalUsername = $externalUserData['unique_username'] ?? null; + if ($localUser->external_username !== $newExternalUsername) { + $changes[] = "External Username: " . ($localUser->external_username ?: 'null') . " → " . ($newExternalUsername ?: 'null'); + } + + // Username + if ($localUser->username !== $newExternalUsername) { + $changes[] = "Username: " . ($localUser->username ?: 'null') . " → " . ($newExternalUsername ?: 'null'); + } + + // Role mapping + $newRole = Role::RESTARTER; // Default role for external users + if (isset($externalUserData['greatest_privilege']) && $externalUserData['greatest_privilege'] === 'Admin') { + $newRole = Role::ADMINISTRATOR; + } + + if ($localUser->role !== $newRole) { + $changes[] = "Role: {$localUser->role} → {$newRole}"; + } + + if (empty($changes)) { + $this->info("No changes needed - user data is already synchronized."); + } else { + $this->info("Changes to be made:"); + foreach ($changes as $change) { + $this->line(" - {$change}"); + } + } + } + + /** + * Perform the actual migration + */ + private function performMigration(User $localUser, array $externalUserData): int + { + try { + // Prepare the update data + $updateData = [ + 'name' => $externalUserData['username'], + 'external_user_id' => $externalUserData['userid'], + 'external_username' => $externalUserData['unique_username'] ?? null, + 'username' => $externalUserData['unique_username'] ?? null, + ]; + + // Map role from iFixit privilege level + $role = Role::RESTARTER; // Default role for external users + if (isset($externalUserData['greatest_privilege']) && $externalUserData['greatest_privilege'] === 'Admin') { + $role = Role::ADMINISTRATOR; + } + $updateData['role'] = $role; + + // Update the user + $localUser->update($updateData); + + // Generate username if not provided + if (!$localUser->username) { + $localUser->generateAndSetUsername(); + $localUser->save(); + } + + $this->info("✓ Migration completed successfully!"); + $this->info("User '{$localUser->email}' is now linked to iFixit user ID: {$externalUserData['userid']}"); + + // Log the migration + Log::info('User migrated to external', [ + 'local_user_id' => $localUser->id, + 'email' => $localUser->email, + 'external_user_id' => $externalUserData['userid'], + 'external_username' => $externalUserData['unique_username'] ?? null + ]); + + return 0; + + } catch (\Exception $e) { + $this->error("Migration failed: {$e->getMessage()}"); + + Log::error('User migration to external failed', [ + 'local_user_id' => $localUser->id, + 'email' => $localUser->email, + 'external_user_data' => $externalUserData, + 'error' => $e->getMessage() + ]); + + return 1; + } + } +} \ No newline at end of file diff --git a/app/Services/Auth/iFixitAuthService.php b/app/Services/Auth/iFixitAuthService.php index e5ca686931..a9cc08c0cd 100644 --- a/app/Services/Auth/iFixitAuthService.php +++ b/app/Services/Auth/iFixitAuthService.php @@ -95,4 +95,38 @@ public function getCurrentUser(): ?array return $this->validateSession($sessionCookie); } + + /** + * Get user data by user ID + * + * @note: This endpoint will not return the user's email address if the caller is not authenticated. + */ + public function getUserById(int $userId): ?array + { + try { + $response = Http::withHeaders([ + 'Accept' => 'application/json', + 'User-Agent' => 'RestartProject/1.0', + ])->get("{$this->apiUrl}/users/{$userId}"); + + if ($response->successful()) { + $userData = $response->json(); + + // Validate required fields + if (!isset($userData['userid'])) { + return null; + } + + return $userData; + } + + return null; + } catch (\Exception $e) { + Log::error('iFixit API user fetch by ID failed', [ + 'error' => $e->getMessage(), + 'user_id' => $userId + ]); + return null; + } + } } \ No newline at end of file