- Turnstile (App\Support\Turnstile, middleware turnstile) cho login admin và login/register/forgot/reset của khách; cấu hình ở setting key=turnstile, secret mã hoá SignRSA. - Menu sidebar khách (setting client_menu) và nút tắt topbar (setting client_quick_links) do admin sửa qua administrator/client-menu[/quick-links], khách đọc v1/setting/menu, v1/setting/quick-links. - v1/setting và settings/public chỉ trả whitelist PublicSetting::FIELDS (trước đây lộ bot_token, inet_api_key). - Cài đặt tài khoản khách (preferences), rate limit theo IP cho register/resend-verify. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
114 lines
3.4 KiB
PHP
114 lines
3.4 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Support;
|
|
|
|
use App\Http\Facade\SignRSA;
|
|
use App\Models\Setting;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Log;
|
|
use InvalidArgumentException;
|
|
use Throwable;
|
|
|
|
final class Turnstile
|
|
{
|
|
public const SETTING_KEY = 'turnstile';
|
|
|
|
public const TOKEN_FIELD = 'cf-turnstile-response';
|
|
|
|
public const SECRET_MASK = '********';
|
|
|
|
private const VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
|
|
|
|
/**
|
|
* @return array{enabled: bool, site_key: string, secret_key: string}
|
|
*/
|
|
public static function stored(): array
|
|
{
|
|
$value = json_decode((string) Setting::where('key', self::SETTING_KEY)->value('value'), true) ?: [];
|
|
|
|
return [
|
|
'enabled' => (bool) ($value['enabled'] ?? false),
|
|
'site_key' => (string) ($value['site_key'] ?? ''),
|
|
'secret_key' => (string) ($value['secret_key'] ?? ''),
|
|
];
|
|
}
|
|
|
|
public static function siteKey(): ?string
|
|
{
|
|
$config = self::stored();
|
|
|
|
return $config['enabled'] && $config['site_key'] !== '' && $config['secret_key'] !== ''
|
|
? $config['site_key']
|
|
: null;
|
|
}
|
|
|
|
public static function verify(?string $token, ?string $ip): bool
|
|
{
|
|
if (self::siteKey() === null) {
|
|
return true;
|
|
}
|
|
|
|
if ($token === null || $token === '' || strlen($token) > 2048) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
$response = Http::asForm()->timeout(10)->post(self::VERIFY_URL, array_filter([
|
|
'secret' => SignRSA::DecryptFunc(self::stored()['secret_key']),
|
|
'response' => $token,
|
|
'remoteip' => $ip,
|
|
]));
|
|
} catch (Throwable $e) {
|
|
Log::warning('Turnstile siteverify failed', ['error' => $e->getMessage()]);
|
|
|
|
return false;
|
|
}
|
|
|
|
if ($response->json('success') !== true) {
|
|
Log::info('Turnstile rejected', ['codes' => $response->json('error-codes')]);
|
|
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $input
|
|
*/
|
|
public static function save(array $input): void
|
|
{
|
|
$current = self::stored();
|
|
$secret = (string) ($input['secret_key'] ?? self::SECRET_MASK);
|
|
|
|
$config = [
|
|
'enabled' => (bool) ($input['enabled'] ?? $current['enabled']),
|
|
'site_key' => trim((string) ($input['site_key'] ?? $current['site_key'])),
|
|
'secret_key' => match (true) {
|
|
$secret === self::SECRET_MASK => $current['secret_key'],
|
|
trim($secret) === '' => '',
|
|
default => SignRSA::EncryptFunc(trim($secret)),
|
|
},
|
|
];
|
|
|
|
if ($config['enabled'] && ($config['site_key'] === '' || $config['secret_key'] === '')) {
|
|
throw new InvalidArgumentException('Cần nhập Site key và Secret key trước khi bật Turnstile.');
|
|
}
|
|
|
|
Setting::updateOrCreate(['key' => self::SETTING_KEY], ['value' => json_encode($config)]);
|
|
}
|
|
|
|
/**
|
|
* @return array{enabled: bool, site_key: string, secret_key: string}
|
|
*/
|
|
public static function forAdmin(): array
|
|
{
|
|
$config = self::stored();
|
|
$config['secret_key'] = $config['secret_key'] === '' ? '' : self::SECRET_MASK;
|
|
|
|
return $config;
|
|
}
|
|
}
|