$this->noAccountsExist(), 'password_policy' => PasswordPolicyService::build($this->config), ]); } public function create(ServerRequestInterface $request): ResponseInterface { $this->enforceSetupRateLimit($request); if (!$this->noAccountsExist()) { throw new ConflictException('Setup has already been completed.'); } $body = $this->getRequestBody($request); $this->requireFields($body, ['username', 'password', 'email']); $username = (string) $body['username']; $password = (string) $body['password']; $email = (string) $body['email']; // Validate username format. Delegate the character rules to the core // helper (Grav\Common\User\DataUser\User::isValidUsername) so setup // accepts exactly what admin-classic does: letters, numbers, periods, // hyphens and underscores, while still blocking path traversal, // leading dots and filesystem-dangerous characters. Keep a 3-64 length // bound for a friendlier message and to match the admin-next UI hint. $length = mb_strlen($username); if ($length < 3 || $length > 64 || !DataUser::isValidUsername($username)) { throw new ValidationException( 'Invalid username format.', [['field' => 'username', 'message' => 'Username must be 3-64 characters and contain only letters, numbers, periods, hyphens, and underscores (and cannot start with a period).']], ); } if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) { throw new ValidationException( 'Invalid email address.', [['field' => 'email', 'message' => 'A valid email address is required.']], ); } $pwdRegex = (string) $this->config->get('system.pwd_regex', ''); if ($pwdRegex !== '' && !@preg_match('#^(?:' . $pwdRegex . ')$#', $password)) { throw new ValidationException( 'Password does not meet the required policy.', [['field' => 'password', 'message' => 'Password does not meet the required policy.']], ); } elseif ($pwdRegex === '' && strlen($password) < 8) { throw new ValidationException( 'Password is too short.', [['field' => 'password', 'message' => 'Password must be at least 8 characters.']], ); } /** @var UserCollectionInterface $accounts */ $accounts = $this->grav['accounts']; // Second race-guard check after acquiring accounts: another concurrent // setup call may have completed between the first check and now. if ($accounts->count() > 0) { throw new ConflictException('Setup has already been completed.'); } $user = $accounts->load($username); $user->set('email', $email); $user->set('fullname', $body['fullname'] ?? $username); $user->set('title', $body['title'] ?? 'Administrator'); $user->set('state', 'enabled'); $user->set('access', [ 'site' => ['login' => true], 'api' => ['super' => true], ]); $user->set('hashed_password', Authentication::create($password)); $user->set('created', time()); $user->set('modified', time()); // Flex user-accounts storage may still hold cached state for this // username from a previous account (avatar, 2FA, content editor, …). // Zero them out so the new super-admin is genuinely fresh. $user->set('avatar', []); $user->set('twofa_enabled', false); $user->set('twofa_secret', ''); $user->save(); $this->fireEvent('onApiUserCreated', ['user' => $user]); $this->fireEvent('onApiSetupComplete', ['user' => $user]); $jwt = new JwtAuthenticator($this->grav, $this->config); return $this->issueTokenPair($jwt, $user); } private function noAccountsExist(): bool { /** @var UserCollectionInterface|null $accounts */ $accounts = $this->grav['accounts'] ?? null; return $accounts !== null && $accounts->count() === 0; } /** * Defense-in-depth: even though this endpoint is self-disabling once any * user exists, rate-limit by IP to blunt rapid brute-force probing during * the eligible window. Reuses the login plugin's rate limiter keyed by a * synthetic "__api_setup__:{ip}" string. */ private function enforceSetupRateLimit(ServerRequestInterface $request): void { if (!class_exists(Login::class) || !isset($this->grav['login'])) { return; } $server = $request->getServerParams(); $ip = (string) ($server['REMOTE_ADDR'] ?? 'unknown'); $key = '__api_setup__:' . $ip; /** @var Login $login */ $login = $this->grav['login']; $interval = $login->checkLoginRateLimit($key); if ($interval > 0) { throw new TooManyRequestsException( sprintf('Too many setup attempts. Try again in %d minutes.', $interval), $interval * 60, ); } } }