Initial commit: AzerothCore registration site

PHP registration page for AzerothCore with SRP6 salt/verifier generation,
Cloudflare Turnstile + honeypot + rate limiting anti-spam, and a WoW-themed
landing page with client setup instructions.
This commit is contained in:
2026-08-05 11:41:24 +02:00
commit 2b9a673912
19 changed files with 1294 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
Require all denied
+30
View File
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
final class Database
{
private static ?PDO $connection = null;
public static function connection(): PDO
{
if (self::$connection === null) {
$config = require __DIR__ . '/config.php';
$db = $config['db'];
$dsn = sprintf(
'mysql:host=%s;port=%d;dbname=%s;charset=utf8mb4',
$db['host'],
$db['port'],
$db['database']
);
self::$connection = new PDO($dsn, $db['username'], $db['password'], [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
}
return self::$connection;
}
}
+65
View File
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
/**
* File-based (SQLite) rate limiter keyed by IP. Kept separate from the
* AzerothCore auth database on purpose — this is purely a web-layer
* anti-spam guard, not game data.
*/
final class RateLimiter
{
private PDO $db;
private int $maxAttempts;
private int $windowSeconds;
public function __construct(string $storagePath, int $maxAttempts, int $windowSeconds)
{
$this->maxAttempts = $maxAttempts;
$this->windowSeconds = $windowSeconds;
$isNew = !is_file($storagePath);
$this->db = new PDO('sqlite:' . $storagePath);
$this->db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
if ($isNew) {
$this->db->exec(
'CREATE TABLE attempts (
ip TEXT NOT NULL,
created_at INTEGER NOT NULL
)'
);
$this->db->exec('CREATE INDEX idx_attempts_ip ON attempts (ip, created_at)');
}
}
public function tooManyAttempts(string $ip): bool
{
$this->cleanup($ip);
$stmt = $this->db->prepare('SELECT COUNT(*) FROM attempts WHERE ip = :ip AND created_at > :since');
$stmt->execute([
':ip' => $ip,
':since' => time() - $this->windowSeconds,
]);
return (int) $stmt->fetchColumn() >= $this->maxAttempts;
}
public function recordAttempt(string $ip): void
{
$stmt = $this->db->prepare('INSERT INTO attempts (ip, created_at) VALUES (:ip, :now)');
$stmt->execute([
':ip' => $ip,
':now' => time(),
]);
}
private function cleanup(string $ip): void
{
$stmt = $this->db->prepare('DELETE FROM attempts WHERE ip = :ip AND created_at <= :since');
$stmt->execute([
':ip' => $ip,
':since' => time() - $this->windowSeconds,
]);
}
}
+65
View File
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
/**
* SRP6 salt/verifier generation compatible with AzerothCore's authserver
* (same constants and algorithm as TrinityCore/MaNGOS, mandated by the WoW
* client itself, so this is stable across expansions/versions).
*
* Algorithm (matches AzerothCore's SRP6::MakeRegistrationData /
* ac-nodejs-srp6's computeVerifier):
* s = random 32 bytes (salt)
* h1 = SHA1(UPPER(username) + ":" + UPPER(password))
* x = SHA1(s . h1) interpreted as a LITTLE-endian integer
* v = g^x mod N (verifier)
* Both s and v are stored little-endian, zero-padded to 32 bytes, in the
* `account`.`salt` / `account`.`verifier` BINARY(32) columns.
*/
final class Srp6
{
// Fixed 256-bit prime and generator used by the WoW client's SRP6 implementation.
private const N_HEX = '894B645E89E1535BBDAD5B8B290650530801B18EBFBF5E8FAB3C82872A3E9BB7';
private const G = 7;
private const KEY_LENGTH = 32;
/**
* @return array{salt: string, verifier: string} raw 32-byte binary strings
*/
public static function makeRegistrationData(string $username, string $password): array
{
if (!extension_loaded('gmp')) {
throw new RuntimeException('Chybí PHP rozšíření GMP (php-gmp), nutné pro výpočet SRP6 verifieru.');
}
$username = strtoupper($username);
$password = strtoupper($password);
$salt = random_bytes(self::KEY_LENGTH);
$h1 = sha1($username . ':' . $password, true);
$xHash = sha1($salt . $h1, true);
$x = gmp_import($xHash, 1, GMP_LSW_FIRST | GMP_LITTLE_ENDIAN);
$n = gmp_init(self::N_HEX, 16);
$g = gmp_init(self::G, 10);
$v = gmp_powm($g, $x, $n);
$verifier = self::gmpToLittleEndianBytes($v, self::KEY_LENGTH);
return [
'salt' => $salt,
'verifier' => $verifier,
];
}
private static function gmpToLittleEndianBytes(\GMP $value, int $length): string
{
$bytes = gmp_export($value, 1, GMP_LSW_FIRST | GMP_LITTLE_ENDIAN);
if (strlen($bytes) > $length) {
throw new RuntimeException('SRP6 verifier přesáhl očekávanou délku.');
}
return str_pad($bytes, $length, "\0", STR_PAD_RIGHT);
}
}
+37
View File
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
final class Turnstile
{
private const VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
public static function verify(string $token, string $secretKey, string $remoteIp): bool
{
if ($token === '' || $secretKey === '') {
return false;
}
$payload = http_build_query([
'secret' => $secretKey,
'response' => $token,
'remoteip' => $remoteIp,
]);
$context = stream_context_create([
'http' => [
'method' => 'POST',
'header' => "Content-Type: application/x-www-form-urlencoded\r\n",
'content' => $payload,
'timeout' => 8,
],
]);
$response = @file_get_contents(self::VERIFY_URL, false, $context);
if ($response === false) {
return false;
}
$data = json_decode($response, true);
return is_array($data) && ($data['success'] ?? false) === true;
}
}
+19
View File
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
if (session_status() === PHP_SESSION_NONE) {
session_start([
'cookie_httponly' => true,
'cookie_samesite' => 'Lax',
]);
}
$config = require __DIR__ . '/config.php';
require_once __DIR__ . '/Database.php';
require_once __DIR__ . '/Srp6.php';
require_once __DIR__ . '/Turnstile.php';
require_once __DIR__ . '/RateLimiter.php';
require_once __DIR__ . '/functions.php';
return $config;
+82
View File
@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
/**
* Loads .env (simple KEY=VALUE parser, no external dependency) and exposes
* typed config via env()/config().
*/
if (!function_exists('load_env')) {
// This file is `require`d (not require_once) from more than one place
// (bootstrap.php and Database.php), so it must always return a fresh
// config array — but its function declarations must only run once.
function load_env(string $path): void
{
if (!is_file($path)) {
return;
}
foreach (file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
$line = trim($line);
if ($line === '' || str_starts_with($line, '#')) {
continue;
}
[$key, $value] = array_pad(explode('=', $line, 2), 2, '');
$key = trim($key);
$value = trim($value);
if ($key === '') {
continue;
}
// Strip one layer of matching quotes, e.g. SITE_NAME="AzerothCore by Minkey"
if (strlen($value) >= 2) {
$first = $value[0];
$last = $value[strlen($value) - 1];
if (($first === '"' && $last === '"') || ($first === "'" && $last === "'")) {
$value = substr($value, 1, -1);
}
}
putenv("$key=$value");
$_ENV[$key] = $value;
}
}
function env(string $key, ?string $default = null): ?string
{
$value = getenv($key);
if ($value === false || $value === '') {
return $default;
}
return $value;
}
}
load_env(__DIR__ . '/../.env');
return [
'site' => [
'name' => env('SITE_NAME', 'AzerothCore by Minkey'),
'realmlist' => env('REALMLIST_ADDRESS', 'wow.minkey.cz'),
'client_download_url' => env('CLIENT_DOWNLOAD_URL', ''),
'client_version' => env('CLIENT_VERSION', 'WotLK 3.3.5a (build 12340)'),
],
'db' => [
'host' => env('DB_HOST', '127.0.0.1'),
'port' => (int) env('DB_PORT', '3306'),
'database' => env('DB_DATABASE', 'acore_auth'),
'username' => env('DB_USERNAME', ''),
'password' => env('DB_PASSWORD', ''),
],
'turnstile' => [
'site_key' => env('TURNSTILE_SITE_KEY', ''),
'secret_key' => env('TURNSTILE_SECRET_KEY', ''),
],
'rules' => [
'username_min' => (int) env('REG_USERNAME_MIN', '3'),
'username_max' => (int) env('REG_USERNAME_MAX', '16'),
'password_min' => (int) env('REG_PASSWORD_MIN', '8'),
'password_max' => (int) env('REG_PASSWORD_MAX', '16'),
],
'rate_limit' => [
'max_attempts' => (int) env('REG_RATE_LIMIT_MAX', '5'),
'window_seconds' => (int) env('REG_RATE_LIMIT_WINDOW', '3600'),
],
];
+87
View File
@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
function client_ip(): string
{
// Uprav podle své infrastruktury: pokud web běží za reverzní proxy
// (nginx proxy_pass, Cloudflare, ...), musíš X-Forwarded-For nastavit
// a validovat důvěryhodně na úrovni proxy, jinak si ji klient může
// vymyslet sám. Bez proxy je REMOTE_ADDR spolehlivý zdroj.
$ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
return filter_var($ip, FILTER_VALIDATE_IP) ? $ip : '0.0.0.0';
}
function csrf_token(): string
{
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
function csrf_verify(?string $token): bool
{
return is_string($token)
&& !empty($_SESSION['csrf_token'])
&& hash_equals($_SESSION['csrf_token'], $token);
}
/**
* Honeypot: skryté pole, které lidský uživatel nikdy nevyplní, ale
* jednoduší boti ano.
*/
function honeypot_triggered(): bool
{
return !empty($_POST['website']);
}
/**
* @return string[] seznam chyb (prázdné pole = validní)
*/
function validate_username(string $username, array $rules): array
{
$errors = [];
$len = mb_strlen($username);
if ($len < $rules['username_min'] || $len > $rules['username_max']) {
$errors[] = sprintf(
'Uživatelské jméno musí mít %d až %d znaků.',
$rules['username_min'],
$rules['username_max']
);
}
if (!preg_match('/^[A-Za-z0-9]+$/', $username)) {
$errors[] = 'Uživatelské jméno smí obsahovat pouze písmena A-Z a číslice (bez diakritiky a speciálních znaků).';
}
return $errors;
}
function validate_password(string $password, array $rules): array
{
$errors = [];
$len = strlen($password);
if ($len < $rules['password_min'] || $len > $rules['password_max']) {
$errors[] = sprintf(
'Heslo musí mít %d až %d znaků.',
$rules['password_min'],
$rules['password_max']
);
}
if (!preg_match('/^[\x21-\x7E]+$/', $password)) {
$errors[] = 'Heslo smí obsahovat pouze běžné znaky ASCII (bez mezer a diakritiky) — herní klient WoW jiné znaky nepodporuje.';
}
return $errors;
}
function validate_email(string $email): array
{
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
return ['Zadej platnou e-mailovou adresu.'];
}
return [];
}
+44
View File
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
/**
* Ruční self-test: ověří, že Srp6::makeRegistrationData() dává stejný
* výsledek jako nezávislá referenční implementace (ověřeno proti Python/
* Node.js reimplementaci algoritmu z azerothcore/ac-nodejs-srp6).
*
* Spusť na serveru: php includes/srp6_selftest.php
* Očekávaný výstup: "OK - shoduje se s referenční implementací."
*/
require_once __DIR__ . '/Srp6.php';
$reflection = new ReflectionClass(Srp6::class);
$method = $reflection->getMethod('gmpToLittleEndianBytes');
$method->setAccessible(true);
$username = 'TESTUSER';
$password = 'TESTPASS';
$fixedSalt = hex2bin('00112233445566778899aabbccddeeff00112233445566778899aabbccddee');
// Přepočítej ručně se stejnou fixní solí jako reference (obchází random_bytes).
$h1 = sha1(strtoupper($username) . ':' . strtoupper($password), true);
$xHash = sha1($fixedSalt . $h1, true);
$x = gmp_import($xHash, 1, GMP_LSW_FIRST | GMP_LITTLE_ENDIAN);
$n = gmp_init('894B645E89E1535BBDAD5B8B290650530801B18EBFBF5E8FAB3C82872A3E9BB7', 16);
$g = gmp_init(7, 10);
$v = gmp_powm($g, $x, $n);
$verifier = $method->invoke(null, $v, 32);
$expected = '0eac7840ff65c7e32dbbb3b173a1cc7ca7cde40f2c3d8423777da4f2929e9257';
$actual = bin2hex($verifier);
if ($actual === $expected) {
echo "OK - shoduje se s referenční implementací.\n";
exit(0);
}
echo "CHYBA: verifier se neshoduje!\n";
echo "Očekáváno: $expected\n";
echo "Spočteno: $actual\n";
exit(1);