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:
@@ -0,0 +1 @@
|
||||
Require all denied
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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'),
|
||||
],
|
||||
];
|
||||
@@ -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 [];
|
||||
}
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user