php-tenant/Console/Commands/HashInvitationTokens.php
Snider a35cbc9306 security: encrypt 2FA secrets and hash invitation tokens
- Add encrypted cast to UserTwoFactorAuth secret and recovery_codes
- Hash invitation tokens on creation using Hash::make()
- Update token verification to use Hash::check()
- Add migration commands for existing data:
  - security:encrypt-2fa-secrets
  - security:hash-invitation-tokens
- Add tests for encryption and hashing

Fixes SEC-003, SEC-004 from security audit.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 12:20:53 +00:00

179 lines
5.8 KiB
PHP

<?php
declare(strict_types=1);
namespace Core\Tenant\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
/**
* Migrate existing plaintext invitation tokens to hashed format.
*
* This command should be run once after deploying the token hashing changes.
* It safely hashes existing tokens that are not yet hashed.
*
* IMPORTANT: After running this migration, existing invitation links will
* no longer work because the plaintext tokens are lost. Consider:
* - Running this during a maintenance window
* - Notifying users with pending invitations to request new ones
* - Or only hashing expired/accepted invitations initially
*/
class HashInvitationTokens extends Command
{
protected $signature = 'security:hash-invitation-tokens
{--dry-run : Preview changes without making them}
{--force : Skip confirmation prompt}
{--pending-only : Only hash pending (active) invitations}
{--exclude-pending : Only hash expired/accepted invitations (safer)}';
protected $description = 'Hash existing plaintext invitation tokens';
public function handle(): int
{
$dryRun = $this->option('dry-run');
$pendingOnly = $this->option('pending-only');
$excludePending = $this->option('exclude-pending');
$query = DB::table('workspace_invitations')
->whereNotNull('token');
if ($pendingOnly) {
$query->whereNull('accepted_at')
->where('expires_at', '>', now());
} elseif ($excludePending) {
$query->where(function ($q) {
$q->whereNotNull('accepted_at')
->orWhere('expires_at', '<=', now());
});
}
$records = $query->get();
if ($records->isEmpty()) {
$this->info('No invitation tokens found. Nothing to migrate.');
return Command::SUCCESS;
}
$toMigrate = [];
$alreadyHashed = 0;
foreach ($records as $record) {
// Check if the token is already hashed (bcrypt hashes start with $2y$)
if ($this->isLikelyHashed($record->token)) {
$alreadyHashed++;
continue;
}
$toMigrate[] = $record;
}
$this->info("Found {$records->count()} invitation records in scope.");
$this->info("Already hashed: {$alreadyHashed}");
$this->info("Need migration: ".count($toMigrate));
if (empty($toMigrate)) {
$this->info('All tokens are already hashed. Nothing to do.');
return Command::SUCCESS;
}
// Count pending vs non-pending
$pendingCount = collect($toMigrate)->filter(fn ($r) => $r->accepted_at === null && $r->expires_at > now()->toDateTimeString())->count();
$nonPendingCount = count($toMigrate) - $pendingCount;
$this->newLine();
$this->warn("IMPORTANT: Hashing tokens is a one-way operation!");
$this->warn("- Pending invitations ({$pendingCount}): Links will STOP working");
$this->warn("- Expired/Accepted ({$nonPendingCount}): Safe to hash");
if ($dryRun) {
$this->newLine();
$this->warn('[DRY RUN] Would hash '.count($toMigrate).' tokens.');
$this->table(
['ID', 'Email', 'Status', 'Token (truncated)'],
collect($toMigrate)->map(fn ($r) => [
$r->id,
$r->email,
$this->getStatus($r),
substr($r->token, 0, 16).'...',
])->toArray()
);
return Command::SUCCESS;
}
if (! $this->option('force') && $pendingCount > 0) {
$this->newLine();
if (! $this->confirm("This will invalidate {$pendingCount} active invitation links. Continue?")) {
$this->warn('Cancelled. Consider using --exclude-pending to only hash old invitations.');
return Command::FAILURE;
}
}
$bar = $this->output->createProgressBar(count($toMigrate));
$bar->start();
$migrated = 0;
$errors = 0;
foreach ($toMigrate as $record) {
try {
$hashedToken = Hash::make($record->token);
DB::table('workspace_invitations')
->where('id', $record->id)
->update(['token' => $hashedToken]);
$migrated++;
} catch (\Throwable $e) {
$this->newLine();
$this->error("Failed to migrate record {$record->id}: {$e->getMessage()}");
$errors++;
}
$bar->advance();
}
$bar->finish();
$this->newLine(2);
$this->info("Migration complete: {$migrated} tokens hashed, {$errors} errors.");
if ($pendingCount > 0 && $errors === 0) {
$this->warn('Remember: Active invitation links will no longer work. Affected users should request new invitations.');
}
return $errors > 0 ? Command::FAILURE : Command::SUCCESS;
}
/**
* Check if a value appears to already be hashed with bcrypt.
*/
protected function isLikelyHashed(string $value): bool
{
// Bcrypt hashes start with $2y$ (or $2a$, $2b$) and are 60 characters
return (bool) preg_match('/^\$2[ayb]\$\d{2}\$/', $value);
}
/**
* Get the status of an invitation for display.
*/
protected function getStatus(object $record): string
{
if ($record->accepted_at !== null) {
return 'Accepted';
}
if ($record->expires_at <= now()->toDateTimeString()) {
return 'Expired';
}
return 'Pending';
}
}