Files
SkinbaseNova/app/Console/Commands/HashLegacyPlainPasswordsCommand.php

218 lines
8.2 KiB
PHP

<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
/**
* Reads plain-text passwords from the legacy `users` table, bcrypt-hashes
* them, and writes a SQL UPDATE file ready to run against the new database.
*
* For users whose password is 'abc123' a strong random password is generated
* first so they are not left with a known weak credential.
*
* Usage:
* php artisan skinbase:hash-legacy-plain-passwords
* php artisan skinbase:hash-legacy-plain-passwords --out=storage/app/hashed-passwords.sql
* php artisan skinbase:hash-legacy-plain-passwords --chunk=1000
*/
class HashLegacyPlainPasswordsCommand extends Command
{
protected $signature = 'skinbase:hash-legacy-plain-passwords
{--out= : Output SQL file path (default: storage/app/hashed-plain-passwords.sql)}
{--chunk=500 : Chunk size for reading legacy users}
{--legacy-connection=legacy : Name of the legacy DB connection}
{--legacy-table=users : Name of the legacy users table}
{--dry-run : Print row count without writing the SQL file}';
protected $description = 'Hash plain-text legacy passwords with bcrypt and export UPDATE SQL. Randomises weak \'abc123\' passwords.';
// Characters for random password generation (no ambiguous l/1/0/O)
private const UPPER = 'ABCDEFGHJKLMNPQRSTUVWXYZ';
private const LOWER = 'abcdefghjkmnpqrstuvwxyz';
private const DIGITS = '23456789';
private const SPECIAL = '!@#$%^&*';
public function handle(): int
{
$outPath = $this->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;
}
}