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.
This commit is contained in:
Andras Bacsai committed 2026-10-26 10:49:19 +01:00
1 parent 88e67cbf42
commit 5d66fb719e
14 files changed
+214 -10

No files matched your search

+8
View File
@@ -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, [
+7
View File
@@ -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) {
+2
View File
@@ -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(),
]);
});
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace App\Support;
use App\Models\User;
use Illuminate\Support\Facades\Cache;
/**
* The first registration creates the root user. It is allowed only for a short time after
* Coolify starts, so a bot cannot claim a fresh install. A restart opens the window again.
*/
class RootSetupWindow
{
public const MINUTES = 10;
private const CACHE_KEY = 'root-setup-window';
public static function openIfNoUserExists(): void
{
if (User::query()->exists()) {
return;
}
Cache::put(self::CACHE_KEY, true, now()->addMinutes(self::MINUTES));
}
public static function isOpen(): bool
{
return Cache::has(self::CACHE_KEY);
}
}
+5 -2
View File
@@ -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')),
]);
+10 -3
View File
@@ -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
+14 -2
View File
@@ -14,10 +14,20 @@ $email = getOldOrLocal('email', 'test3@example.com');
<x-auth.shell title="Coolify"
:description="$isFirstUser ? 'Create the root account for this instance.' : 'Create your account to get started.'">
<div class="flex flex-col gap-4">
@if ($isFirstUser)
@if ($isRootSetupWindowClosed)
<x-auth.alert type="warning">
<p class="font-medium">Setup window closed</p>
<p class="mt-0.5 text-black/70 dark:text-white/70">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:</p>
<code class="mt-2 block font-mono text-xs">docker restart coolify</code>
</x-auth.alert>
@elseif ($isFirstUser)
<x-auth.alert type="warning">
<p class="font-medium">Full instance access</p>
<p class="mt-0.5 text-black/70 dark:text-white/70">This first account becomes the root user.</p>
<p class="mt-0.5 text-black/70 dark:text-white/70">This first account becomes the root user.
Create it within {{ \App\Support\RootSetupWindow::MINUTES }} minutes after
Coolify starts.</p>
</x-auth.alert>
@endif
@@ -31,6 +41,7 @@ $email = getOldOrLocal('email', 'test3@example.com');
</x-auth.alert>
@endif
@unless ($isRootSetupWindowClosed)
<form action="/register" method="POST" class="flex flex-col gap-4">
@csrf
<x-forms.input id="name" required type="text" name="name" value="{{ $name }}" autofocus
@@ -51,6 +62,7 @@ $email = getOldOrLocal('email', 'test3@example.com');
Create account
</x-forms.button>
</form>
@endunless
</div>
@if (! $isFirstUser)
+10 -3
View File
@@ -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
@@ -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,
+67
View File
@@ -0,0 +1,67 @@
<?php
use App\Models\InstanceSettings;
use App\Models\User;
use App\Support\RootSetupWindow;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Testing\TestResponse;
uses(RefreshDatabase::class);
beforeEach(function () {
config()->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();
});
+39
View File
@@ -0,0 +1,39 @@
<?php
use App\Models\InstanceSettings;
use App\Models\Team;
use App\Models\User;
use Database\Seeders\RootUserSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::query()->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();
});
+3
View File
@@ -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,
+3
View File
@@ -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,
+12
View File
@@ -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');