feat(auth): clusev:reset-admin CLI lockout fallback
Shell-only emergency recovery: resets the admin password (prints a random one if omitted) and, with --disable-2fa, clears the 2FA secret + backup codes so the operator can re-enroll. Requires server access — the safe last resort. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>feat/v1-foundation
parent
abaed12b0b
commit
f6a65827fd
|
|
@ -0,0 +1,60 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Emergency, shell-only recovery: resets the admin password (and optionally 2FA)
|
||||
* when the operator is locked out (lost password + authenticator + backup codes).
|
||||
* Requires server access, so it is a safe last resort.
|
||||
*/
|
||||
class ResetAdmin extends Command
|
||||
{
|
||||
protected $signature = 'clusev:reset-admin
|
||||
{--email= : E-Mail des Admin-Kontos (sonst der einzige Benutzer)}
|
||||
{--password= : Neues Passwort (zufällig erzeugt, falls leer)}
|
||||
{--disable-2fa : 2FA + Backup-Codes ebenfalls zurücksetzen}';
|
||||
|
||||
protected $description = 'Notfall-Reset: setzt das Admin-Passwort (und optional 2FA) per Shell zurück';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$email = (string) $this->option('email');
|
||||
$user = $email !== ''
|
||||
? User::where('email', $email)->first()
|
||||
: (User::count() === 1 ? User::first() : null);
|
||||
|
||||
if (! $user) {
|
||||
$this->error($email !== '' ? "Kein Benutzer mit E-Mail {$email}." : 'Kein eindeutiger Benutzer — bitte --email angeben.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$password = (string) ($this->option('password') ?: Str::password(20));
|
||||
$user->forceFill(['password' => Hash::make($password), 'must_change_password' => false]);
|
||||
|
||||
if ($this->option('disable-2fa')) {
|
||||
$user->forceFill([
|
||||
'two_factor_secret' => null,
|
||||
'two_factor_confirmed_at' => null,
|
||||
'two_factor_recovery_codes' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
$user->save();
|
||||
|
||||
$this->info("Passwort für {$user->email} zurückgesetzt.");
|
||||
if (! $this->option('password')) {
|
||||
$this->warn("Einmal-Passwort (jetzt notieren): {$password}");
|
||||
}
|
||||
if ($this->option('disable-2fa')) {
|
||||
$this->info('2FA + Backup-Codes entfernt — beim nächsten Login neu einrichten.');
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ResetAdminCommandTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_resets_password_and_disables_2fa(): void
|
||||
{
|
||||
$user = User::factory()->create([
|
||||
'email' => 'admin@clusev.local',
|
||||
'two_factor_secret' => 'x', 'two_factor_confirmed_at' => now(),
|
||||
]);
|
||||
$user->replaceRecoveryCodes();
|
||||
|
||||
$this->artisan('clusev:reset-admin', ['--email' => 'admin@clusev.local', '--password' => 'BrandNew1234', '--disable-2fa' => true])
|
||||
->assertExitCode(0);
|
||||
|
||||
$user->refresh();
|
||||
$this->assertTrue(Hash::check('BrandNew1234', $user->password));
|
||||
$this->assertNull($user->two_factor_secret);
|
||||
$this->assertNull($user->two_factor_confirmed_at);
|
||||
$this->assertFalse($user->hasRecoveryCodes());
|
||||
}
|
||||
|
||||
public function test_unknown_email_fails(): void
|
||||
{
|
||||
$this->artisan('clusev:reset-admin', ['--email' => 'nope@x.test'])->assertExitCode(1);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue