218 lines
8.2 KiB
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;
|
|
}
|
|
}
|