Files
baodev0andClaude Opus 5.5 e888a6ed5d feat: Turnstile captcha, menu khách + nút tắt topbar cấu hình từ admin, ẩn setting nhạy cảm
- 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>
2026-10-12 15:53:43 +07:00

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;
}
}