diff --git a/app/Console/Commands/Install.php b/app/Console/Commands/Install.php index efc64d7..8796647 100644 --- a/app/Console/Commands/Install.php +++ b/app/Console/Commands/Install.php @@ -4,22 +4,19 @@ namespace App\Console\Commands; use App\Models\User; use Illuminate\Console\Command; +use Illuminate\Support\Str; /** * Idempotent first-run bootstrap (called by install.sh, phase 7). * - * Creates the initial admin with the standard default password (operator decision: a - * known, memorable default so login is never blocked by a missed one-time secret). - * `must_change_password` is set, so the onboarding gate FORCES a new password on the - * first login — the default is only valid until then. Re-running once an admin exists is - * a hard no-op. NOTE: a known default credential is only acceptable because of the forced - * rotation; the operator must change it immediately (banner / MOTD / README say so). + * Creates the initial admin with a RANDOM one-time password (printed once by install.sh from the + * CLUSEV_ADMIN_PASSWORD line below) rather than a guessable literal — rotation is optional now, so a + * well-known default like "clusev" would be a standing takeover credential. `must_change_password` is + * still set (the banner nudges a rotation), but even if kept, the generated secret is not guessable. + * Re-running once an admin exists is a hard no-op. */ class Install extends Command { - /** Standard default admin password — forced to change on first login (must_change_password). */ - private const DEFAULT_PASSWORD = 'clusev'; - /** * Standard default admin e-mail (login + Let's-Encrypt contact) when none is given. A fixed, * predictable value so a fresh, unattended install always logs in at admin@clusev.local — @@ -50,22 +47,23 @@ class Install extends Command return self::FAILURE; } - $password = self::DEFAULT_PASSWORD; + // Random, non-guessable one-time password (printed once below). Satisfies any policy. + $password = Str::password(16); $user = User::create([ 'name' => (string) ($this->option('name') ?: 'Administrator'), 'email' => $email, 'password' => $password, // 'hashed' cast hashes on save - 'must_change_password' => true, // forces a new password on first login + 'must_change_password' => true, // nudges a rotation on first login (optional) ]); - // install.sh greps these two lines for the closing banner. The password is the - // standard default and must be changed on first login (must_change_password). + // install.sh greps these two lines for the closing banner — this is the ONLY time the + // generated password is shown, so the operator must note it now. $this->newLine(); $this->line('CLUSEV_ADMIN_EMAIL='.$user->email); $this->line('CLUSEV_ADMIN_PASSWORD='.$password); $this->newLine(); - $this->info('Admin angelegt. Standard-Passwort — beim ersten Login sofort aendern.'); + $this->info('Admin angelegt. Generiertes Passwort jetzt notieren — es wird nur einmal angezeigt.'); return self::SUCCESS; } diff --git a/install.sh b/install.sh index 30861c9..dd50efc 100755 --- a/install.sh +++ b/install.sh @@ -429,7 +429,7 @@ info "+----------------------------------------------------------+" info " Dashboard: ${ACCESS_URL}" info " Admin-Login: ${ADMIN_MAIL:-}" if [ -n "$ADMIN_PW" ]; then - info " Admin-Passwort: ${ADMIN_PW} (Standard — beim ersten Login sofort aendern!)" + info " Admin-Passwort: ${ADMIN_PW} (generiert — JETZT notieren, wird nur einmal angezeigt!)" else info " Admin-Passwort: " fi diff --git a/tests/Feature/InstallCommandTest.php b/tests/Feature/InstallCommandTest.php index 01095e5..8350919 100644 --- a/tests/Feature/InstallCommandTest.php +++ b/tests/Feature/InstallCommandTest.php @@ -4,38 +4,63 @@ namespace Tests\Feature; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Hash; use Tests\TestCase; /** - * clusev:install (install.sh phase 8) — first-admin bootstrap. The operator chose a known - * default password ('clusev') for predictable login; must_change_password forces a new one - * on first login, which is what keeps the default acceptable. + * clusev:install (install.sh phase 8) — first-admin bootstrap. The password is a RANDOM one-time + * secret printed once (CLUSEV_ADMIN_PASSWORD=…), not a guessable literal; must_change_password + * nudges a rotation on first login (optional). */ class InstallCommandTest extends TestCase { use RefreshDatabase; - public function test_creates_admin_with_default_password_and_forced_change(): void + /** Run the command and return the password it printed on the CLUSEV_ADMIN_PASSWORD line. */ + private function install(array $args = []): string { - $this->artisan('clusev:install', ['--email' => 'admin@example.test'])->assertSuccessful(); + $this->assertSame(0, Artisan::call('clusev:install', $args)); + $out = Artisan::output(); // capture once — the buffer is cleared after the first read + $this->assertMatchesRegularExpression('/CLUSEV_ADMIN_PASSWORD=(\S+)/', $out, 'must print the generated password once'); + preg_match('/CLUSEV_ADMIN_PASSWORD=(\S+)/', $out, $m); + + return $m[1] ?? ''; + } + + public function test_creates_admin_with_a_generated_password_and_forced_change(): void + { + $pw = $this->install(['--email' => 'admin@example.test']); + + // Not the old guessable literal, and strong enough to resist guessing even if kept. + $this->assertNotSame('clusev', $pw); + $this->assertGreaterThanOrEqual(12, strlen($pw)); $user = User::where('email', 'admin@example.test')->first(); $this->assertNotNull($user); - $this->assertTrue(Hash::check('clusev', $user->password), 'admin password must be the default "clusev"'); - $this->assertTrue((bool) $user->must_change_password, 'first login must be forced to change it'); + $this->assertTrue(Hash::check($pw, $user->password), 'the printed password must authenticate'); + $this->assertTrue((bool) $user->must_change_password, 'first login should still nudge a change'); } public function test_uses_the_fixed_default_email_when_none_is_given(): void { - $this->artisan('clusev:install')->assertSuccessful(); + $pw = $this->install(); $user = User::sole(); $this->assertSame('admin@clusev.local', $user->email, 'a fresh install must default to the predictable admin@clusev.local'); - $this->assertTrue(Hash::check('clusev', $user->password)); + $this->assertTrue(Hash::check($pw, $user->password)); $this->assertTrue((bool) $user->must_change_password); } + public function test_generates_a_distinct_password_each_install(): void + { + $first = $this->install(['--email' => 'a@example.test']); + User::query()->delete(); + $second = $this->install(['--email' => 'b@example.test']); + + $this->assertNotSame($first, $second, 'the generated password must not be a fixed value'); + } + public function test_is_a_noop_when_an_admin_already_exists(): void { User::factory()->create();