security: generate a random install password instead of the literal 'clusev'
Rotation is optional now, so a well-known default ('clusev') would be a standing takeover
credential on a fleet-root console. The installer now creates the first admin with a random
Str::password(16), printed once on the CLUSEV_ADMIN_PASSWORD= line that install.sh already
surfaces in the closing banner. must_change_password still nudges a rotation, but even if the
operator keeps it, the secret is no longer guessable.
Verified: the printed password authenticates + differs per install; noop-when-admin-exists is
intact; Codex review APPROVED.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/v1-foundation
parent
fd4f6ceb0f
commit
c3788f5851
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -429,7 +429,7 @@ info "+----------------------------------------------------------+"
|
|||
info " Dashboard: ${ACCESS_URL}"
|
||||
info " Admin-Login: ${ADMIN_MAIL:-<bestehend>}"
|
||||
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: <bereits installiert — kein neuer Admin angelegt>"
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue