diff --git a/README.md b/README.md index 43466da..0eac83b 100644 --- a/README.md +++ b/README.md @@ -48,13 +48,18 @@ Verifier-Server is a web server designed to handle BYOND user verification. #DB_OPTIONS= # MySQL configuration - DB_DSN=mysql:host=127.0.0.1;port=3306;dbname=verify_list - DB_PORT=3306 - DB_USERNAME=your_username - DB_PASSWORD=your_password + #DB_DSN=mysql:host=127.0.0.1;port=3306;dbname=verify_list + #DB_PORT=3306 + #DB_USERNAME=your_username + #DB_PASSWORD=your_password #DB_OPTIONS={"option1":"value1","option2":"value2"} ``` + The `.env` file lives in the repository root regardless of the working + directory; variables set in the real environment take precedence over it. + The OAuth2 web endpoints (`SS14_OAUTH2_CLIENT_ID`/`SECRET`, `dwa_client_id`/ + `dwa_client_secret`) are optional and only registered when both halves are set. + ## Usage 1. Start the server: diff --git a/example.env b/example.env index 09ef151..c9b7a0c 100644 --- a/example.env +++ b/example.env @@ -4,6 +4,7 @@ TOKEN=changeme STORAGE_TYPE=filesystem #CWD-relative path to the JSON file JSON_PATH=json/verify.json +SS14_JSON_PATH=json/ss14verify.json # SQLite configuration #DB_DSN=sqlite:verify.db @@ -12,8 +13,14 @@ JSON_PATH=json/verify.json #DB_OPTIONS= # MySQL configuration -DB_DSN=mysql:host=127.0.0.1;port=3306;dbname=verify_list -DB_PORT=3306 -DB_USERNAME=your_username -DB_PASSWORD=your_password +#DB_DSN=mysql:host=127.0.0.1;port=3306;dbname=verify_list +#DB_PORT=3306 +#DB_USERNAME=your_username +#DB_PASSWORD=your_password #DB_OPTIONS={"option1":"value1","option2":"value2"} + +# Optional OAuth2 web endpoints (/ss14wa and /dwa); left unset, they are not registered. +#SS14_OAUTH2_CLIENT_ID= +#SS14_OAUTH2_CLIENT_SECRET= +#dwa_client_id= +#dwa_client_secret= diff --git a/run.php b/run.php index aa21853..8611340 100644 --- a/run.php +++ b/run.php @@ -24,12 +24,12 @@ $envConfig['STORAGE_TYPE'] ?? 'filesystem', $envConfig['SS14_JSON_PATH'] ?? 'json/ss14verify.json', ]); -$server->setSS14OAuth2Endpoint( - $_ENV['SS14_OAUTH2_CLIENT_ID'] ?? getenv('SS14_OAUTH2_CLIENT_ID'), - $_ENV['SS14_OAUTH2_CLIENT_SECRET'] ?? getenv('SS14_OAUTH2_CLIENT_SECRET') -); -$server->setDiscordOAuth2Endpoint( - $_ENV['dwa_client_id'] ?? getenv('dwa_client_id'), - $_ENV['dwa_client_secret'] ?? getenv('dwa_client_secret') -); +// OAuth2 web endpoints are optional; they are only registered when both credentials are configured. +$oauth = static fn(string $key): string => (string) ($_ENV[$key] ?? getenv($key) ?: ''); +if ($oauth('SS14_OAUTH2_CLIENT_ID') !== '' && $oauth('SS14_OAUTH2_CLIENT_SECRET') !== '') { + $server->setSS14OAuth2Endpoint($oauth('SS14_OAUTH2_CLIENT_ID'), $oauth('SS14_OAUTH2_CLIENT_SECRET')); +} +if ($oauth('dwa_client_id') !== '' && $oauth('dwa_client_secret') !== '') { + $server->setDiscordOAuth2Endpoint($oauth('dwa_client_id'), $oauth('dwa_client_secret')); +} $server->start(true); // Start the server and the event loop diff --git a/src/VerifierServer/OAuth2Authenticator.php b/src/VerifierServer/OAuth2Authenticator.php index 80d85c1..57dc69a 100644 --- a/src/VerifierServer/OAuth2Authenticator.php +++ b/src/VerifierServer/OAuth2Authenticator.php @@ -82,7 +82,7 @@ public function __construct( $this->state = isset($this->sessions[$this->endpoint_name][$this->requesting_ip], $this->sessions[$this->endpoint_name][$this->requesting_ip]['state']) ? $this->sessions[$this->endpoint_name][$this->requesting_ip]['state'] - : $this->sessions[$this->endpoint_name][$this->requesting_ip]['state'] = uniqid(); + : $this->sessions[$this->endpoint_name][$this->requesting_ip]['state'] = bin2hex(random_bytes(16)); // unguessable CSRF state if (isset($this->sessions[$this->endpoint_name][$this->requesting_ip]['access_token'])) { $this->access_token = $this->sessions[$this->endpoint_name][$this->requesting_ip]['access_token']; diff --git a/src/VerifierServer/PersistentState.php b/src/VerifierServer/PersistentState.php index 602e0a1..1c975c9 100644 --- a/src/VerifierServer/PersistentState.php +++ b/src/VerifierServer/PersistentState.php @@ -175,57 +175,70 @@ public function getJsonPath(): string return getcwd() . '/' . $this->json_path; } + /** Keys returned by loadEnvConfig() with their defaults when absent from the environment. */ + public const ENV_DEFAULTS = [ + 'HOST_ADDR' => '127.0.0.1', + 'HOST_PORT' => '8080', + 'TOKEN' => 'changeme', + 'STORAGE_TYPE' => 'filesystem', + 'JSON_PATH' => 'json/verify.json', + 'SS14_JSON_PATH' => 'json/ss14verify.json', + 'DB_DSN' => '', + 'DB_PORT' => '', + 'DB_USERNAME' => '', + 'DB_PASSWORD' => '', + 'DB_OPTIONS' => '', + ]; + /** - * Loads environment configuration from a .env file. - * If the .env file does not exist, it creates one with default values. - * - * @return array The environment configuration as an associative array. + * Loads environment configuration from the `.env` file in `$directory` + * (the repository root by default). If the file does not exist, it is + * created with default values. Variables already present in the real + * environment take precedence over the file. + * + * @return array The environment configuration as an associative array + * with every key of ENV_DEFAULTS present. * @throws Exception If the TOKEN is 'changeme' and the HOST_ADDR is not '127.0.0.1'. - * - * After loading the .env file, it checks if the TOKEN is 'changeme' and the HOST_ADDR is not '127.0.0.1'. - * If this condition is met, the script terminates with an error message. - * */ - public static function loadEnvConfig(): array + public static function loadEnvConfig(?string $directory = null): array { - if (! file_exists(".env")) { - file_put_contents(".env", - "HOST_ADDR=127.0.0.1" . PHP_EOL . - "HOST_PORT=8080" . PHP_EOL . - "TOKEN=changeme" . PHP_EOL . - "STORAGE_TYPE=filesystem" . PHP_EOL . + $directory = $directory ?? dirname(__DIR__, 2); + $file = rtrim($directory, '/\\') . DIRECTORY_SEPARATOR . '.env'; + if (! file_exists($file)) { + file_put_contents($file, + "HOST_ADDR=127.0.0.1" . PHP_EOL . + "HOST_PORT=8080" . PHP_EOL . + "TOKEN=changeme" . PHP_EOL . + "STORAGE_TYPE=filesystem" . PHP_EOL . "JSON_PATH=json/verify.json" . PHP_EOL . - "# SQLite configuration" . PHP_EOL . - "#DB_DSN=sqlite:verify.db" . PHP_EOL . - "#DB_USERNAME=" . PHP_EOL . - "#DB_PASSWORD=" . PHP_EOL . - "#DB_OPTIONS=" . PHP_EOL . - "# MySQL configuration" . PHP_EOL . - "DB_DSN=mysql:host=127.0.0.1;port=3306;dbname=verify_list" . PHP_EOL . - "DB_PORT=3306" . PHP_EOL . - "DB_USERNAME=your_username" . PHP_EOL . - "DB_PASSWORD=your_password" . PHP_EOL . - "#DB_OPTIONS={\"option1\":\"value1\",\"option2\":\"value2\"}" + "SS14_JSON_PATH=json/ss14verify.json" . PHP_EOL . + "# SQLite configuration" . PHP_EOL . + "#DB_DSN=sqlite:verify.db" . PHP_EOL . + "#DB_USERNAME=" . PHP_EOL . + "#DB_PASSWORD=" . PHP_EOL . + "#DB_OPTIONS=" . PHP_EOL . + "# MySQL configuration" . PHP_EOL . + "#DB_DSN=mysql:host=127.0.0.1;port=3306;dbname=verify_list" . PHP_EOL . + "#DB_PORT=3306" . PHP_EOL . + "#DB_USERNAME=your_username" . PHP_EOL . + "#DB_PASSWORD=your_password" . PHP_EOL . + "#DB_OPTIONS={\"option1\":\"value1\",\"option2\":\"value2\"}" . PHP_EOL ); } - $dotenv = Dotenv::createImmutable(__DIR__ . '\\..\\..\\'); - $dotenv->load(); + $loaded = Dotenv::createImmutable($directory)->load(); - $env = $_ENV; + $env = []; + foreach (self::ENV_DEFAULTS as $key => $default) { + $value = $_ENV[$key] ?? $_SERVER[$key] ?? null; + if ($value === null) $value = getenv($key); + if ($value === false) $value = $loaded[$key] ?? null; + $env[$key] = $value === null ? $default : (string) $value; + } if ($env['TOKEN'] === 'changeme' && $env['HOST_ADDR'] !== '127.0.0.1') { - die("Cannot use default token with non-localhost address! "); + throw new Exception('Cannot use default token with non-localhost address!'); } - return [ - 'HOST_ADDR' => $env['HOST_ADDR'], - 'HOST_PORT' => $env['HOST_PORT'], - 'TOKEN' => $env['TOKEN'], - 'DB_DSN' => $env['DB_DSN'], - 'DB_PORT' => $env['DB_PORT'], - 'DB_USERNAME' => $env['DB_USERNAME'], - 'DB_PASSWORD' => $env['DB_PASSWORD'], - 'DB_OPTIONS' => $env['DB_OPTIONS'] ?? '' - ]; + return $env; } /** diff --git a/tests/isolated/EnvConfigTest.php b/tests/isolated/EnvConfigTest.php new file mode 100644 index 0000000..60772ce --- /dev/null +++ b/tests/isolated/EnvConfigTest.php @@ -0,0 +1,67 @@ +directory = sys_get_temp_dir() . '/verifier-env-' . bin2hex(random_bytes(4)); + mkdir($this->directory); + $this->clearEnvironment(); + } + + protected function tearDown(): void + { + @unlink($this->directory . '/.env'); + rmdir($this->directory); + $this->clearEnvironment(); + } + + /** Dotenv is immutable: a value already in the environment wins, so each test starts clean. */ + private function clearEnvironment(): void + { + foreach (array_keys(PersistentState::ENV_DEFAULTS) as $key) { + unset($_ENV[$key], $_SERVER[$key]); + putenv($key); + } + } + + public function testMissingFileIsCreatedWithLocalhostDefaultsAndNoWarnings(): void + { + $config = PersistentState::loadEnvConfig($this->directory); + self::assertFileExists($this->directory . '/.env'); + self::assertSame(PersistentState::ENV_DEFAULTS, $config); + self::assertStringNotContainsString("\nDB_DSN=", file_get_contents($this->directory . '/.env'), 'no live database line in the generated file'); + } + + public function testFileValuesAreReadFromTheGivenDirectory(): void + { + file_put_contents($this->directory . '/.env', "HOST_ADDR=0.0.0.0\nHOST_PORT=16261\nTOKEN=secret\nSTORAGE_TYPE=database\nDB_DSN=sqlite:verify.db\n"); + $config = PersistentState::loadEnvConfig($this->directory); + self::assertSame('0.0.0.0', $config['HOST_ADDR']); + self::assertSame('16261', $config['HOST_PORT']); + self::assertSame('secret', $config['TOKEN']); + self::assertSame('database', $config['STORAGE_TYPE']); + self::assertSame('sqlite:verify.db', $config['DB_DSN']); + self::assertSame('', $config['DB_USERNAME']); + self::assertSame('json/verify.json', $config['JSON_PATH']); + } + + public function testRealEnvironmentOverridesTheFile(): void + { + file_put_contents($this->directory . '/.env', "TOKEN=from-file\n"); + $_SERVER['TOKEN'] = 'from-environment'; // how a real environment variable appears to dotenv + self::assertSame('from-environment', PersistentState::loadEnvConfig($this->directory)['TOKEN']); + } + + public function testDefaultTokenOnAPublicAddressIsRefused(): void + { + file_put_contents($this->directory . '/.env', "HOST_ADDR=0.0.0.0\nTOKEN=changeme\n"); + $this->expectException(Exception::class); + PersistentState::loadEnvConfig($this->directory); + } +}