option('out') ?: storage_path('app/hashed-plain-passwords.sql'); $chunk = max(1, (int) ($this->option('chunk') ?? 500)); $legacyConn = (string) ($this->option('legacy-connection') ?? 'legacy'); $legacyTable = (string) ($this->option('legacy-table') ?? 'users'); $dryRun = (bool) $this->option('dry-run'); // Verify legacy connection is available try { DB::connection($legacyConn)->getPdo(); } catch (\Throwable $e) { $this->error('Cannot connect to legacy DB: ' . $e->getMessage()); return self::FAILURE; } $now = now()->format('Y-m-d H:i:s'); $newDbName = DB::getDatabaseName(); $lines = []; $lines[] = '-- Hashed plain-password export'; $lines[] = '-- Generated: ' . $now; $lines[] = '-- Source: legacy DB (read-only) — passwords bcrypt-hashed for Laravel'; $lines[] = '-- WARNING: this file contains sensitive data. Delete after applying.'; $lines[] = ''; $lines[] = 'SET NAMES utf8mb4;'; $lines[] = 'USE `' . $newDbName . '`;'; $lines[] = 'START TRANSACTION;'; $lines[] = ''; $processed = 0; $randomised = 0; $skipped = 0; $chunkNum = 0; // Count total for progress bar $total = DB::connection($legacyConn) ->table($legacyTable) ->where('should_migrate', 1) ->count(); $this->info("Legacy DB: {$total} users with should_migrate=1 found."); $this->info("Output : " . ($dryRun ? '(dry-run, no file)' : $outPath)); $this->newLine(); $bar = $this->output->createProgressBar($total); $bar->setFormat(" %current%/%max% [%bar%] %percent:3s%% mem:%memory:6s%\n %message%"); $bar->setMessage('Starting…'); $bar->start(); DB::connection($legacyConn) ->table($legacyTable) ->select(['user_id', 'password']) ->where('should_migrate', 1) ->orderBy('user_id') ->chunk($chunk, function ($rows) use (&$lines, &$processed, &$randomised, &$skipped, &$chunkNum, $now, $bar, $chunk) { $chunkNum++; $bar->setMessage("chunk #{$chunkNum} (chunk size {$chunk})"); foreach ($rows as $row) { $userId = (int) ($row->user_id ?? 0); $plain = trim((string) ($row->password ?? '')); if ($userId <= 0 || $plain === '') { $bar->setMessage("user_id={$userId} SKIPPED (empty)"); $bar->advance(); $skipped++; continue; } // Skip entries that already look like a bcrypt / argon hash if (preg_match('/^\$2[aby]\$|^\$argon2/', $plain)) { $lines[] = "-- USER ID: {$userId} (already hashed — skipped)"; $lines[] = ''; $bar->setMessage("user_id={$userId} SKIPPED (already hashed)"); $bar->advance(); $skipped++; continue; } $commentPlain = $plain; $tag = 'hashed'; if ($plain === 'abc123') { $newPlain = $this->generateStrongPassword(); $commentPlain = "abc123 => {$newPlain}"; $plain = $newPlain; $tag = 'RANDOMISED (was abc123)'; $randomised++; } $bcrypt = Hash::make($plain); $escaped = str_replace(['\\', "'"], ['\\\\', "\\'"], $bcrypt); $lines[] = "-- USER ID: {$userId} PASS: {$commentPlain}"; $lines[] = "SAVEPOINT sp_{$userId};"; $lines[] = "UPDATE `users` SET `password` = '{$escaped}' WHERE `id` = {$userId};"; $lines[] = ''; $bar->setMessage("user_id={$userId} {$tag}"); $bar->advance(); $processed++; } }); $bar->setMessage("Done."); $bar->finish(); $this->newLine(2); $lines[] = 'COMMIT;'; $lines[] = ''; $lines[] = "-- Total processed : {$processed}"; $lines[] = "-- Passwords randomised (abc123) : {$randomised}"; $lines[] = "-- Rows skipped (empty / already hashed) : {$skipped}"; $this->table( ['Metric', 'Count'], [ ['Processed (hashed)', $processed], ['Randomised (abc123)', $randomised], ['Skipped', $skipped], ['Total should_migrate=1', $total], ] ); if ($dryRun) { $this->info('Dry-run mode — SQL file not written.'); return self::SUCCESS; } $dir = dirname($outPath); if (!is_dir($dir) && !mkdir($dir, 0750, true)) { $this->error("Cannot create output directory: {$dir}"); return self::FAILURE; } $sql = implode("\n", $lines) . "\n"; if (file_put_contents($outPath, $sql) === false) { $this->error("Cannot write SQL file: {$outPath}"); return self::FAILURE; } $this->info("SQL written to: {$outPath}"); return self::SUCCESS; } /** * Generate a cryptographically random strong password. * Format: 4 upper + 4 lower + 3 digits + 2 special = 13 chars, then shuffled. */ private function generateStrongPassword(): string { $password = ''; $password .= $this->randomChars(self::UPPER, 4); $password .= $this->randomChars(self::LOWER, 4); $password .= $this->randomChars(self::DIGITS, 3); $password .= $this->randomChars(self::SPECIAL, 2); // Shuffle with a cryptographically random permutation $chars = str_split($password); for ($i = count($chars) - 1; $i > 0; $i--) { $j = random_int(0, $i); [$chars[$i], $chars[$j]] = [$chars[$j], $chars[$i]]; } return implode('', $chars); } private function randomChars(string $pool, int $count): string { $out = ''; $max = strlen($pool) - 1; for ($i = 0; $i < $count; $i++) { $out .= $pool[random_int(0, $max)]; } return $out; } }