Merge pull request #384 from specialpointcentral/php8

[RFC] Refine captcha configuration and drivers
This commit is contained in:
xiaomlove
2025-10-12 04:18:55 +07:00
committed by GitHub
16 changed files with 734 additions and 100 deletions

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Services\Captcha;
interface CaptchaDriverInterface
{
public function isEnabled(): bool;
/**
* Render the captcha markup for HTML forms.
*/
public function render(array $context = []): string;
/**
* Verify the captcha response.
*/
public function verify(array $payload, array $context = []): bool;
}

View File

@@ -0,0 +1,120 @@
<?php
namespace App\Services\Captcha;
use App\Services\Captcha\Exceptions\CaptchaValidationException;
use Illuminate\Support\Arr;
class CaptchaManager
{
/** @var array<string, CaptchaDriverInterface> */
protected array $drivers = [];
protected ?array $config = null;
public function driver(?string $name = null): CaptchaDriverInterface
{
$name = $name ?? $this->getDefaultDriver();
$driver = $this->getDriverInstance($name);
if ($name !== 'image' && !$driver->isEnabled()) {
return $this->driver('image');
}
return $driver;
}
public function render(array $context = []): string
{
return $this->driver()->render($context);
}
public function verify(array $payload, array $context = []): bool
{
try {
return $this->driver()->verify($payload, $context);
} catch (CaptchaValidationException $exception) {
throw $exception;
}
}
public function isEnabled(): bool
{
return $this->driver()->isEnabled();
}
protected function getDriverInstance(string $name): CaptchaDriverInterface
{
if (!isset($this->drivers[$name])) {
try {
$this->drivers[$name] = $this->resolveDriver($name);
} catch (\InvalidArgumentException $exception) {
if ($name !== 'image') {
return $this->getDriverInstance('image');
}
throw $exception;
}
}
return $this->drivers[$name];
}
protected function resolveDriver(string $name): CaptchaDriverInterface
{
$config = $this->getConfigValue("drivers.$name", []);
if (!is_array($config) || empty($config)) {
throw new \InvalidArgumentException("Captcha driver [$name] is not defined.");
}
$driverClass = Arr::get($config, 'class');
if (!$driverClass || !class_exists($driverClass)) {
throw new \InvalidArgumentException("Captcha driver class for [$name] is invalid.");
}
$driver = new $driverClass($config);
if (!$driver instanceof CaptchaDriverInterface) {
throw new \InvalidArgumentException("Captcha driver [$name] must implement " . CaptchaDriverInterface::class);
}
return $driver;
}
protected function getDefaultDriver(): string
{
return (string) $this->getConfigValue('default', 'image');
}
protected function getConfigValue(string $key, $default = null)
{
if ($this->config === null) {
$config = null;
if (function_exists('app')) {
try {
$repository = app('config');
if ($repository) {
$config = $repository->get('captcha');
}
} catch (\Throwable $exception) {
$config = null;
}
}
if (!is_array($config) && function_exists('nexus_config')) {
$config = nexus_config('captcha', []);
}
if (!is_array($config)) {
$path = (defined('ROOT_PATH') ? ROOT_PATH : dirname(__DIR__, 3) . DIRECTORY_SEPARATOR) . 'config/captcha.php';
$config = is_file($path) ? require $path : [];
}
$this->config = is_array($config) ? $config : [];
}
return Arr::get($this->config, $key, $default);
}
}

View File

@@ -0,0 +1,156 @@
<?php
namespace App\Services\Captcha\Drivers;
use App\Services\Captcha\CaptchaDriverInterface;
use App\Services\Captcha\Exceptions\CaptchaValidationException;
class ImageCaptchaDriver implements CaptchaDriverInterface
{
protected array $config;
public function __construct(array $config = [])
{
$this->config = $config;
}
public function isEnabled(): bool
{
return true;
}
public function render(array $context = []): string
{
$labels = $context['labels'] ?? [];
$imageLabel = $labels['image'] ?? 'Security Image';
$codeLabel = $labels['code'] ?? 'Security Code';
$secret = $context['secret'] ?? '';
$imagehash = $this->issue();
$imageUrl = htmlspecialchars(sprintf('image.php?action=regimage&imagehash=%s&secret=%s', $imagehash, $secret ?? ''), ENT_QUOTES, 'UTF-8');
return implode("\n", [
sprintf('<tr><td class="rowhead">%s</td><td align="left"><img src="%s" border="0" alt="CAPTCHA" /></td></tr>', htmlspecialchars($imageLabel, ENT_QUOTES, 'UTF-8'), $imageUrl),
sprintf('<tr><td class="rowhead">%s</td><td align="left"><input type="text" autocomplete="off" style="width: 180px; border: 1px solid gray" name="imagestring" value="" /><input type="hidden" name="imagehash" value="%s" /></td></tr>', htmlspecialchars($codeLabel, ENT_QUOTES, 'UTF-8'), htmlspecialchars($imagehash, ENT_QUOTES, 'UTF-8')),
]);
}
public function verify(array $payload, array $context = []): bool
{
$imagehash = trim((string) ($payload['imagehash'] ?? ''));
$imagestring = trim((string) ($payload['imagestring'] ?? ''));
if ($imagehash === '' || $imagestring === '') {
throw new CaptchaValidationException('Missing captcha parameters.');
}
$query = sprintf(
"SELECT dateline FROM regimages WHERE imagehash='%s' AND imagestring='%s'",
mysql_real_escape_string($imagehash),
mysql_real_escape_string($imagestring)
);
$sql = sql_query($query);
$imgcheck = mysql_fetch_array($sql);
$this->deleteByHash($imagehash);
if (empty($imgcheck['dateline'])) {
throw new CaptchaValidationException('Invalid captcha response.');
}
return true;
}
public function issue(): string
{
$random = random_str();
$imagehash = md5($random);
$dateline = time();
$sql = sprintf(
"INSERT INTO `regimages` (`imagehash`, `imagestring`, `dateline`) VALUES ('%s', '%s', '%s')",
mysql_real_escape_string($imagehash),
mysql_real_escape_string($random),
mysql_real_escape_string((string) $dateline)
);
sql_query($sql);
return $imagehash;
}
public function outputImage(string $imagehash): void
{
$query = sprintf(
"SELECT imagestring FROM regimages WHERE imagehash=%s",
sqlesc($imagehash)
);
$sql = sql_query($query);
$regimage = mysql_fetch_array($sql);
$imagestring = $regimage['imagestring'] ?? '';
if ($imagestring === '') {
$this->renderFallback();
return;
}
$characters = implode(' ', str_split($imagestring));
if (!function_exists('imagecreatefrompng')) {
$this->renderFallback();
return;
}
$fontwidth = imageFontWidth(5);
$fontheight = imageFontHeight(5);
$textwidth = $fontwidth * strlen($characters);
$textheight = $fontheight;
$randimg = rand(1, 5);
$imagePath = ROOT_PATH . "public/pic/regimages/reg{$randimg}.png";
if (!is_file($imagePath)) {
$this->renderFallback();
return;
}
$im = imagecreatefrompng($imagePath);
$imgheight = imagesy($im);
$imgwidth = imagesx($im);
$textposh = (int) floor(($imgwidth - $textwidth) / 2);
$textposv = (int) floor(($imgheight - $textheight) / 2);
$dots = (int) floor($imgheight * $imgwidth / 35);
for ($i = 1; $i <= $dots; $i++) {
imagesetpixel($im, rand(0, $imgwidth - 1), rand(0, $imgheight - 1), imagecolorallocate($im, rand(0, 255), rand(0, 255), rand(0, 255)));
}
$textcolor = imagecolorallocate($im, 0, 0, 0);
imagestring($im, 5, $textposh, $textposv, $characters, $textcolor);
header('Content-type: image/png');
imagepng($im);
imagedestroy($im);
}
protected function deleteByHash(string $imagehash): void
{
if ($imagehash === '') {
return;
}
$delete = sprintf(
"DELETE FROM regimages WHERE imagehash='%s'",
mysql_real_escape_string($imagehash)
);
sql_query($delete);
}
protected function renderFallback(): void
{
http_response_code(404);
}
}

View File

@@ -0,0 +1,134 @@
<?php
namespace App\Services\Captcha\Drivers;
use App\Services\Captcha\CaptchaDriverInterface;
use App\Services\Captcha\Exceptions\CaptchaValidationException;
class RecaptchaV2CaptchaDriver implements CaptchaDriverInterface
{
protected array $config;
public function __construct(array $config = [])
{
$this->config = $config;
}
public function isEnabled(): bool
{
return !empty($this->config['site_key']) && !empty($this->config['secret_key']);
}
public function render(array $context = []): string
{
if (!$this->isEnabled()) {
return '';
}
$labels = $context['labels'] ?? [];
$label = $labels['image'] ?? $labels['code'] ?? 'Security Check';
$theme = $this->config['theme'] ?? 'light';
$size = $this->config['size'] ?? 'normal';
$validSizes = ['compact', 'normal'];
if (!in_array($size, $validSizes, true)) {
$size = 'normal';
}
$attributes = sprintf(
'class="g-recaptcha" data-sitekey="%s" data-theme="%s" data-size="%s"',
htmlspecialchars($this->config['site_key'], ENT_QUOTES, 'UTF-8'),
htmlspecialchars($theme, ENT_QUOTES, 'UTF-8'),
htmlspecialchars($size, ENT_QUOTES, 'UTF-8')
);
return sprintf(
'<tr><td class="rowhead">%s</td><td align="left"><div %s></div>%s</td></tr>',
htmlspecialchars($label, ENT_QUOTES, 'UTF-8'),
$attributes,
'<script src="https://www.google.com/recaptcha/api.js" async defer></script>'
);
}
public function verify(array $payload, array $context = []): bool
{
$token = trim((string) ($payload['request']['g-recaptcha-response'] ?? ''));
if ($token === '') {
throw new CaptchaValidationException('Captcha verification token is missing.');
}
$secret = $this->config['secret_key'] ?? '';
if ($secret === '') {
throw new CaptchaValidationException('Captcha secret key is not configured.');
}
$data = [
'secret' => $secret,
'response' => $token,
];
$remoteIp = $context['ip'] ?? null;
if (!empty($remoteIp)) {
$data['remoteip'] = $remoteIp;
}
$result = $this->sendVerificationRequest('https://www.google.com/recaptcha/api/siteverify', $data);
if (!($result['success'] ?? false)) {
throw new CaptchaValidationException('Captcha verification failed.');
}
return true;
}
protected function sendVerificationRequest(string $url, array $data): array
{
$payload = http_build_query($data);
if (function_exists('curl_init')) {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_TIMEOUT => 10,
CURLOPT_SSL_VERIFYPEER => true,
]);
$response = curl_exec($ch);
if ($response === false) {
$error = curl_error($ch);
curl_close($ch);
throw new CaptchaValidationException('Captcha verification request failed: ' . $error);
}
curl_close($ch);
} else {
$context = stream_context_create([
'http' => [
'method' => 'POST',
'header' => "Content-type: application/x-www-form-urlencoded\r\n",
'content' => $payload,
'timeout' => 10,
],
]);
$response = file_get_contents($url, false, $context);
if ($response === false) {
throw new CaptchaValidationException('Captcha verification request failed.');
}
}
$decoded = json_decode($response, true);
if (!is_array($decoded)) {
throw new CaptchaValidationException('Unexpected captcha verification response.');
}
return $decoded;
}
}

View File

@@ -0,0 +1,143 @@
<?php
namespace App\Services\Captcha\Drivers;
use App\Services\Captcha\CaptchaDriverInterface;
use App\Services\Captcha\Exceptions\CaptchaValidationException;
class TurnstileCaptchaDriver implements CaptchaDriverInterface
{
protected static bool $scriptInjected = false;
protected array $config;
public function __construct(array $config = [])
{
$this->config = $config;
}
public function isEnabled(): bool
{
return !empty($this->config['site_key']) && !empty($this->config['secret_key']);
}
public function render(array $context = []): string
{
if (!$this->isEnabled()) {
return '';
}
$labels = $context['labels'] ?? [];
$label = $labels['image'] ?? $labels['code'] ?? 'Security Check';
$theme = $this->config['theme'] ?? 'light';
$size = $this->config['size'] ?? 'auto';
if (is_string($size)) {
$size = strtolower($size);
}
$validSizes = ['compact', 'normal', 'flexible'];
if (!in_array($size, $validSizes, true)) {
$size = 'auto';
}
$attributes = sprintf(
'class="cf-turnstile" data-sitekey="%s" data-theme="%s"%s',
htmlspecialchars($this->config['site_key'], ENT_QUOTES, 'UTF-8'),
htmlspecialchars($theme, ENT_QUOTES, 'UTF-8'),
$size === 'auto' ? '' : sprintf(' data-size="%s"', htmlspecialchars($size, ENT_QUOTES, 'UTF-8'))
);
$markup = sprintf(
'<tr><td class="rowhead">%s</td><td align="left"><div %s></div>%s</td></tr>',
htmlspecialchars($label, ENT_QUOTES, 'UTF-8'),
$attributes,
self::$scriptInjected ? '' : '<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>'
);
self::$scriptInjected = true;
return $markup;
}
public function verify(array $payload, array $context = []): bool
{
$token = trim((string) ($payload['request']['cf-turnstile-response'] ?? ''));
if ($token === '') {
throw new CaptchaValidationException('Captcha verification token is missing.');
}
$secret = $this->config['secret_key'] ?? '';
if ($secret === '') {
throw new CaptchaValidationException('Captcha secret key is not configured.');
}
$data = [
'secret' => $secret,
'response' => $token,
];
$remoteIp = $context['ip'] ?? null;
if (!empty($remoteIp)) {
$data['remoteip'] = $remoteIp;
}
$result = $this->sendVerificationRequest('https://challenges.cloudflare.com/turnstile/v0/siteverify', $data);
if (!($result['success'] ?? false)) {
throw new CaptchaValidationException('Captcha verification failed.');
}
return true;
}
protected function sendVerificationRequest(string $url, array $data): array
{
$payload = http_build_query($data);
if (function_exists('curl_init')) {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_TIMEOUT => 10,
CURLOPT_SSL_VERIFYPEER => true,
]);
$response = curl_exec($ch);
if ($response === false) {
$error = curl_error($ch);
curl_close($ch);
throw new CaptchaValidationException('Captcha verification request failed: ' . $error);
}
curl_close($ch);
} else {
$context = stream_context_create([
'http' => [
'method' => 'POST',
'header' => "Content-type: application/x-www-form-urlencoded\r\n",
'content' => $payload,
'timeout' => 10,
],
]);
$response = file_get_contents($url, false, $context);
if ($response === false) {
throw new CaptchaValidationException('Captcha verification request failed.');
}
}
$decoded = json_decode($response, true);
if (!is_array($decoded)) {
throw new CaptchaValidationException('Unexpected captcha verification response.');
}
return $decoded;
}
}

View File

@@ -0,0 +1,10 @@
<?php
namespace App\Services\Captcha\Exceptions;
use RuntimeException;
class CaptchaValidationException extends RuntimeException
{
}