From 5d66fb719ef535d643ce33260099dcb6d54a44a8 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 26 Oct 2026 10:49:19 +0100 Subject: [PATCH] fix(registration): time-limit first root account creation Open root registration for ten minutes after startup, then require a Coolify restart to reopen it. Update installer credential handling and explain the setup window in the registration UI. --- app/Actions/Fortify/CreateNewUser.php | 8 +++ app/Console/Commands/Init.php | 7 ++ app/Providers/FortifyServiceProvider.php | 2 + app/Support/RootSetupWindow.php | 31 +++++++++ database/seeders/RootUserSeeder.php | 7 +- other/nightly/install.sh | 13 +++- resources/views/auth/register.blade.php | 16 ++++- scripts/install.sh | 13 +++- tests/Feature/OauthRegistrationPolicyTest.php | 3 + tests/Feature/RootSetupWindowTest.php | 67 +++++++++++++++++++ tests/Feature/RootUserSeederTest.php | 39 +++++++++++ tests/Support/BrowserTestHelpers.php | 3 + tests/v4/Browser/LoginTest.php | 3 + tests/v4/Browser/RegistrationTest.php | 12 ++++ 14 files changed, 214 insertions(+), 10 deletions(-) create mode 100644 app/Support/RootSetupWindow.php create mode 100644 tests/Feature/RootSetupWindowTest.php create mode 100644 tests/Feature/RootUserSeederTest.php diff --git a/app/Actions/Fortify/CreateNewUser.php b/app/Actions/Fortify/CreateNewUser.php index 1a8d3332a1..730b0b0873 100644 --- a/app/Actions/Fortify/CreateNewUser.php +++ b/app/Actions/Fortify/CreateNewUser.php @@ -5,11 +5,13 @@ namespace App\Actions\Fortify; use App\Jobs\SendVerificationEmailJob; use App\Models\Team; use App\Models\User; +use App\Support\RootSetupWindow; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\Validator; use Illuminate\Validation\Rule; use Illuminate\Validation\Rules\Password; +use Illuminate\Validation\ValidationException; use Laravel\Fortify\Contracts\CreatesNewUsers; class CreateNewUser implements CreatesNewUsers @@ -34,6 +36,12 @@ class CreateNewUser implements CreatesNewUsers abort(403); } + if (! User::query()->exists() && ! RootSetupWindow::isOpen()) { + throw ValidationException::withMessages([ + 'email' => 'The setup window for the root account is closed. Restart Coolify on the server (docker restart coolify) to open it again.', + ]); + } + $this->ensureRegistrationIsNotRateLimited($input); Validator::make($input, [ diff --git a/app/Console/Commands/Init.php b/app/Console/Commands/Init.php index e21a2f1c81..6cd914fd9c 100644 --- a/app/Console/Commands/Init.php +++ b/app/Console/Commands/Init.php @@ -16,6 +16,7 @@ use App\Models\Server; use App\Models\StandalonePostgresql; use App\Models\User; use App\Support\ResourceStartActivity; +use App\Support\RootSetupWindow; use Carbon\Carbon; use Illuminate\Console\Command; use Illuminate\Support\Facades\Artisan; @@ -36,6 +37,12 @@ class Init extends Command Artisan::call('optimize:clear'); Artisan::call('optimize'); + try { + RootSetupWindow::openIfNoUserExists(); + } catch (\Throwable $e) { + echo "Could not open the root setup window: {$e->getMessage()}\n"; + } + try { $this->pullTemplatesFromCDN(); } catch (\Throwable $e) { diff --git a/app/Providers/FortifyServiceProvider.php b/app/Providers/FortifyServiceProvider.php index d6519aac0e..8a2e049d97 100644 --- a/app/Providers/FortifyServiceProvider.php +++ b/app/Providers/FortifyServiceProvider.php @@ -8,6 +8,7 @@ use App\Actions\Fortify\UpdateUserPassword; use App\Models\OauthSetting; use App\Models\User; use App\Support\PasskeyRelyingParty; +use App\Support\RootSetupWindow; use Illuminate\Http\Request; use Illuminate\Support\Facades\Hash; use Illuminate\Support\ServiceProvider; @@ -51,6 +52,7 @@ class FortifyServiceProvider extends ServiceProvider return view('auth.register', [ 'isFirstUser' => $isFirstUser, + 'isRootSetupWindowClosed' => $isFirstUser && ! RootSetupWindow::isOpen(), ]); }); diff --git a/app/Support/RootSetupWindow.php b/app/Support/RootSetupWindow.php new file mode 100644 index 0000000000..f07180176f --- /dev/null +++ b/app/Support/RootSetupWindow.php @@ -0,0 +1,31 @@ +exists()) { + return; + } + + Cache::put(self::CACHE_KEY, true, now()->addMinutes(self::MINUTES)); + } + + public static function isOpen(): bool + { + return Cache::has(self::CACHE_KEY); + } +} diff --git a/database/seeders/RootUserSeeder.php b/database/seeders/RootUserSeeder.php index 9bc93a9a9e..a15f04e33f 100644 --- a/database/seeders/RootUserSeeder.php +++ b/database/seeders/RootUserSeeder.php @@ -25,9 +25,12 @@ class RootUserSeeder extends Seeder return; } + // The installer writes an empty ROOT_USERNAME line, so an empty value must also use the default. + $username = env('ROOT_USERNAME') ?: 'Root User'; + $validator = Validator::make([ 'email' => env('ROOT_USER_EMAIL'), - 'username' => env('ROOT_USERNAME', 'Root User'), + 'username' => $username, 'password' => env('ROOT_USER_PASSWORD'), ], [ 'email' => ['required', 'email:rfc,dns', 'max:255'], @@ -48,7 +51,7 @@ class RootUserSeeder extends Seeder try { $user = (new User)->forceFill([ 'id' => 0, - 'name' => env('ROOT_USERNAME', 'Root User'), + 'name' => $username, 'email' => env('ROOT_USER_EMAIL'), 'password' => Hash::make(env('ROOT_USER_PASSWORD')), ]); diff --git a/other/nightly/install.sh b/other/nightly/install.sh index 842e0e769d..b00f35da86 100755 --- a/other/nightly/install.sh +++ b/other/nightly/install.sh @@ -966,12 +966,16 @@ update_env_var "PUSHER_APP_KEY" "$(openssl rand -hex 32)" update_env_var "PUSHER_APP_SECRET" "$(openssl rand -hex 32)" update_env_var "PUSHER_BACKEND_PORT" "6001" -# Add default root user credentials from environment variables -if [ -n "$ROOT_USERNAME" ] && [ -n "$ROOT_USER_EMAIL" ] && [ -n "$ROOT_USER_PASSWORD" ]; then +# Add default root user credentials from environment variables (ROOT_USERNAME is optional) +if [ -n "$ROOT_USER_EMAIL" ] && [ -n "$ROOT_USER_PASSWORD" ]; then echo " - Setting predefined root user credentials from environment" - update_env_var "ROOT_USERNAME" "$(quote_env_value "$ROOT_USERNAME")" + if [ -n "$ROOT_USERNAME" ]; then + update_env_var "ROOT_USERNAME" "$(quote_env_value "$ROOT_USERNAME")" + fi update_env_var "ROOT_USER_EMAIL" "$(quote_env_value "$ROOT_USER_EMAIL")" update_env_var "ROOT_USER_PASSWORD" "$(quote_env_value "$ROOT_USER_PASSWORD")" +elif [ -n "$ROOT_USERNAME" ] || [ -n "$ROOT_USER_EMAIL" ] || [ -n "$ROOT_USER_PASSWORD" ]; then + warn "Predefined root user skipped: set both ROOT_USER_EMAIL and ROOT_USER_PASSWORD." fi if [ -n "${REGISTRY_URL+x}" ]; then @@ -1163,6 +1167,9 @@ for IP in $PRIVATE_IPS; do fi done +if [ "$IS_COOLIFY_VOLUME_EXISTS" -eq 0 ] && { [ -z "$ROOT_USER_EMAIL" ] || [ -z "$ROOT_USER_PASSWORD" ]; }; then + printf '\n %s! Create the root account within 10 minutes. If the time ends, run %sdocker restart coolify%s%s and try again.%s\n' "$C_YELLOW" "$C_BOLD" "$C_RESET" "$C_YELLOW" "$C_RESET" >&3 +fi printf '\n %s! Back up %s/data/coolify/source/.env%s%s to a safe place outside this server (e.g. a password manager).%s\n' "$C_YELLOW" "$C_BOLD" "$C_RESET" "$C_YELLOW" "$C_RESET" >&3 printf ' %sLog file: %s%s\n\n' "$C_DIM" "$INSTALLATION_LOG_WITH_DATE" "$C_RESET" >&3 diff --git a/resources/views/auth/register.blade.php b/resources/views/auth/register.blade.php index 160495bd27..3e652ff50e 100644 --- a/resources/views/auth/register.blade.php +++ b/resources/views/auth/register.blade.php @@ -14,10 +14,20 @@ $email = getOldOrLocal('email', 'test3@example.com');
- @if ($isFirstUser) + @if ($isRootSetupWindowClosed) + +

Setup window closed

+

You can create the root account only in the + first {{ \App\Support\RootSetupWindow::MINUTES }} minutes after Coolify + starts. To open the window again, run this command on the server, then reload this page:

+ docker restart coolify +
+ @elseif ($isFirstUser)

Full instance access

-

This first account becomes the root user.

+

This first account becomes the root user. + Create it within {{ \App\Support\RootSetupWindow::MINUTES }} minutes after + Coolify starts.

@endif @@ -31,6 +41,7 @@ $email = getOldOrLocal('email', 'test3@example.com'); @endif + @unless ($isRootSetupWindowClosed)
@csrf + @endunless
@if (! $isFirstUser) diff --git a/scripts/install.sh b/scripts/install.sh index 25ab62a754..905421675e 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -966,12 +966,16 @@ update_env_var "PUSHER_APP_KEY" "$(openssl rand -hex 32)" update_env_var "PUSHER_APP_SECRET" "$(openssl rand -hex 32)" update_env_var "PUSHER_BACKEND_PORT" "6001" -# Add default root user credentials from environment variables -if [ -n "$ROOT_USERNAME" ] && [ -n "$ROOT_USER_EMAIL" ] && [ -n "$ROOT_USER_PASSWORD" ]; then +# Add default root user credentials from environment variables (ROOT_USERNAME is optional) +if [ -n "$ROOT_USER_EMAIL" ] && [ -n "$ROOT_USER_PASSWORD" ]; then echo " - Setting predefined root user credentials from environment" - update_env_var "ROOT_USERNAME" "$(quote_env_value "$ROOT_USERNAME")" + if [ -n "$ROOT_USERNAME" ]; then + update_env_var "ROOT_USERNAME" "$(quote_env_value "$ROOT_USERNAME")" + fi update_env_var "ROOT_USER_EMAIL" "$(quote_env_value "$ROOT_USER_EMAIL")" update_env_var "ROOT_USER_PASSWORD" "$(quote_env_value "$ROOT_USER_PASSWORD")" +elif [ -n "$ROOT_USERNAME" ] || [ -n "$ROOT_USER_EMAIL" ] || [ -n "$ROOT_USER_PASSWORD" ]; then + warn "Predefined root user skipped: set both ROOT_USER_EMAIL and ROOT_USER_PASSWORD." fi if [ -n "${REGISTRY_URL+x}" ]; then @@ -1163,6 +1167,9 @@ for IP in $PRIVATE_IPS; do fi done +if [ "$IS_COOLIFY_VOLUME_EXISTS" -eq 0 ] && { [ -z "$ROOT_USER_EMAIL" ] || [ -z "$ROOT_USER_PASSWORD" ]; }; then + printf '\n %s! Create the root account within 10 minutes. If the time ends, run %sdocker restart coolify%s%s and try again.%s\n' "$C_YELLOW" "$C_BOLD" "$C_RESET" "$C_YELLOW" "$C_RESET" >&3 +fi printf '\n %s! Back up %s/data/coolify/source/.env%s%s to a safe place outside this server (e.g. a password manager).%s\n' "$C_YELLOW" "$C_BOLD" "$C_RESET" "$C_YELLOW" "$C_RESET" >&3 printf ' %sLog file: %s%s\n\n' "$C_DIM" "$INSTALLATION_LOG_WITH_DATE" "$C_RESET" >&3 diff --git a/tests/Feature/OauthRegistrationPolicyTest.php b/tests/Feature/OauthRegistrationPolicyTest.php index 09cede4586..c77c18c2bd 100644 --- a/tests/Feature/OauthRegistrationPolicyTest.php +++ b/tests/Feature/OauthRegistrationPolicyTest.php @@ -3,6 +3,7 @@ use App\Actions\Fortify\CreateNewUser; use App\Models\InstanceSettings; use App\Models\OauthSetting; +use App\Support\RootSetupWindow; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Once; use Symfony\Component\HttpKernel\Exception\HttpException; @@ -10,6 +11,8 @@ use Symfony\Component\HttpKernel\Exception\HttpException; uses(RefreshDatabase::class); beforeEach(function () { + RootSetupWindow::openIfNoUserExists(); + InstanceSettings::forceCreate([ 'id' => 0, 'is_registration_enabled' => true, diff --git a/tests/Feature/RootSetupWindowTest.php b/tests/Feature/RootSetupWindowTest.php new file mode 100644 index 0000000000..cd46cc5aea --- /dev/null +++ b/tests/Feature/RootSetupWindowTest.php @@ -0,0 +1,67 @@ +set('app.maintenance.store', 'array'); + + InstanceSettings::query()->forceCreate([ + 'id' => 0, + 'is_registration_enabled' => true, + ]); +}); + +function registerRootAccount(): TestResponse +{ + return test()->post('/register', [ + 'name' => 'Root User', + 'email' => 'root@example.com', + 'password' => 'Password1!@', + 'password_confirmation' => 'Password1!@', + ]); +} + +it('blocks the root registration when the setup window was never opened', function () { + registerRootAccount()->assertSessionHasErrors('email'); + + expect(User::count())->toBe(0); +}); + +it('blocks the root registration after the setup window ends', function () { + RootSetupWindow::openIfNoUserExists(); + $this->travel(RootSetupWindow::MINUTES * 60 + 1)->seconds(); + + registerRootAccount()->assertSessionHasErrors('email'); + + expect(User::count())->toBe(0); +}); + +it('creates the root user during the setup window', function () { + RootSetupWindow::openIfNoUserExists(); + $this->travel(RootSetupWindow::MINUTES - 1)->minutes(); + + registerRootAccount()->assertSessionHasNoErrors(); + + expect(User::find(0)?->email)->toBe('root@example.com'); +}); + +it('shows how to open the setup window again when it is closed', function () { + $this->get('/register') + ->assertOk() + ->assertSee('docker restart coolify') + ->assertDontSee('Create account'); +}); + +it('does not open the setup window when a user exists', function () { + User::factory()->create(['id' => 0]); + + RootSetupWindow::openIfNoUserExists(); + + expect(RootSetupWindow::isOpen())->toBeFalse(); +}); diff --git a/tests/Feature/RootUserSeederTest.php b/tests/Feature/RootUserSeederTest.php new file mode 100644 index 0000000000..d4fc66798e --- /dev/null +++ b/tests/Feature/RootUserSeederTest.php @@ -0,0 +1,39 @@ +forceCreate(['id' => 0, 'is_registration_enabled' => true]); + Team::factory()->create(['id' => 0, 'personal_team' => true]); + Http::fake(['api.pwnedpasswords.com/*' => Http::response('')]); + + // The .env template of the installer has an empty ROOT_USERNAME line. + putenv('ROOT_USERNAME='); + putenv('ROOT_USER_EMAIL=root@coollabs.io'); + putenv('ROOT_USER_PASSWORD=Rw-Seeder-Test-Aa1!'); +}); + +afterEach(function () { + putenv('ROOT_USERNAME'); + putenv('ROOT_USER_EMAIL'); + putenv('ROOT_USER_PASSWORD'); +}); + +it('creates the root user when ROOT_USERNAME is empty', function () { + ob_start(); + (new RootUserSeeder)->run(); + ob_end_clean(); + + expect(User::find(0)) + ->not->toBeNull() + ->name->toBe('Root User') + ->email->toBe('root@coollabs.io'); + expect(InstanceSettings::find(0)->is_registration_enabled)->toBeFalse(); +}); diff --git a/tests/Support/BrowserTestHelpers.php b/tests/Support/BrowserTestHelpers.php index 19d346e099..ce5ac16610 100644 --- a/tests/Support/BrowserTestHelpers.php +++ b/tests/Support/BrowserTestHelpers.php @@ -25,6 +25,7 @@ use App\Models\StandalonePostgresql; use App\Models\StandaloneRedis; use App\Models\Team; use App\Models\User; +use App\Support\RootSetupWindow; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Str; @@ -33,6 +34,8 @@ use Illuminate\Support\Str; */ function seedBrowserInstanceSettings(array $attributes = []): InstanceSettings { + RootSetupWindow::openIfNoUserExists(); + return InstanceSettings::forceCreate(array_merge([ 'id' => 0, 'is_sponsorship_popup_enabled' => false, diff --git a/tests/v4/Browser/LoginTest.php b/tests/v4/Browser/LoginTest.php index df960a71b1..fb483a63b9 100644 --- a/tests/v4/Browser/LoginTest.php +++ b/tests/v4/Browser/LoginTest.php @@ -2,6 +2,7 @@ use App\Models\InstanceSettings; use App\Models\User; +use App\Support\RootSetupWindow; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Hash; use Laravel\Fortify\Fortify; @@ -9,6 +10,8 @@ use Laravel\Fortify\Fortify; uses(RefreshDatabase::class); beforeEach(function () { + RootSetupWindow::openIfNoUserExists(); + // `id` is not mass-assignable; forceCreate so InstanceSettings::get() (id 0) works. InstanceSettings::forceCreate([ 'id' => 0, diff --git a/tests/v4/Browser/RegistrationTest.php b/tests/v4/Browser/RegistrationTest.php index d72c5653c3..d37fff28f0 100644 --- a/tests/v4/Browser/RegistrationTest.php +++ b/tests/v4/Browser/RegistrationTest.php @@ -2,6 +2,7 @@ use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Cache; uses(RefreshDatabase::class); @@ -18,6 +19,17 @@ it('shows registration page when no users exist', function () { ->screenshot(filename: 'registration-root-setup'); }); +it('shows how to open the setup window again when it is closed', function () { + Cache::flush(); + + $page = visit('/register'); + + $page->assertSee('Setup window closed') + ->assertSee('docker restart coolify') + ->assertDontSee('Create account') + ->screenshot(filename: 'registration-root-setup-window-closed'); +}); + it('can register a new root user', function () { $page = visit('/register');