Rewrite captcha core around aksrt_captcha_config only, require captcha on login/comment/SMS/phone auth and related gates, wire frontend token collection, and remove dead dual-source security settings plus slider enqueue.
417 lines
15 KiB
PHP
417 lines
15 KiB
PHP
<?php
|
||
/**
|
||
* AKSRT SMS — 短信服务编排层
|
||
*
|
||
* 负责验证码生成、限流、过期、provider 选择和统一结果结构。
|
||
* 真实发送逻辑委托给 AKSRT_SMS_Provider 子类。
|
||
*
|
||
* 对齐 zibll 的安全特性:
|
||
* - 发送间隔限制(transient)
|
||
* - 每日发送上限(transient)
|
||
* - 错误频率限制(防暴力破解,transient)
|
||
* - 验证码绑定目标手机号
|
||
* - 服务端返回剩余等待时间
|
||
* - 人机验证钩子
|
||
*
|
||
* @package AKSRT
|
||
*/
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
class AKSRT_SMS {
|
||
|
||
private static $instance = null;
|
||
|
||
public static function get_instance(): self {
|
||
if (null === self::$instance) self::$instance = new self();
|
||
return self::$instance;
|
||
}
|
||
|
||
public function init(): void {
|
||
add_action('wp_ajax_aksrt_send_sms_code', [$this, 'ajax_send_code']);
|
||
add_action('wp_ajax_nopriv_aksrt_send_sms_code', [$this, 'ajax_send_code']);
|
||
add_action('wp_ajax_aksrt_sms_test', [$this, 'ajax_test_provider']);
|
||
add_action('wp_ajax_aksrt_sms_status', [$this, 'ajax_provider_status']);
|
||
}
|
||
|
||
/**
|
||
* 获取短信供应商配置
|
||
*/
|
||
public function get_provider_config(): array {
|
||
if (class_exists('AKSRT_Settings')) {
|
||
return AKSRT_Settings::get_sms_settings();
|
||
}
|
||
return ['sms_enabled' => false, 'sms_provider' => ''];
|
||
}
|
||
|
||
/**
|
||
* 获取当前 provider adapter
|
||
*/
|
||
public function get_provider(): ?AKSRT_SMS_Provider {
|
||
$config = $this->get_provider_config();
|
||
$key = sanitize_key((string) ($config['sms_provider'] ?? ''));
|
||
return AKSRT_SMS_Provider_Factory::get($key);
|
||
}
|
||
|
||
/**
|
||
* 发送短信验证码
|
||
*
|
||
* @param string $phone 手机号
|
||
* @return array{success: bool, message: string, remaining_time?: int}
|
||
*/
|
||
public function send_code(string $phone): array {
|
||
$config = $this->get_provider_config();
|
||
|
||
if (empty($config['sms_enabled'])) {
|
||
return ['success' => false, 'message' => __('短信服务未启用', 'aksrt')];
|
||
}
|
||
|
||
$provider_key = sanitize_key((string) ($config['sms_provider'] ?? ''));
|
||
if ($provider_key === '') {
|
||
return ['success' => false, 'message' => __('未配置短信供应商', 'aksrt')];
|
||
}
|
||
|
||
$provider = AKSRT_SMS_Provider_Factory::get($provider_key);
|
||
if ($provider === null) {
|
||
return ['success' => false, 'message' => sprintf(__('短信供应商 "%s" 不可用', 'aksrt'), $provider_key)];
|
||
}
|
||
|
||
// 校验配置完整性
|
||
$missing = $provider->validate_config($config);
|
||
if (!empty($missing)) {
|
||
return ['success' => false, 'message' => sprintf(__('短信配置不完整,缺少: %s', 'aksrt'), implode(', ', $missing))];
|
||
}
|
||
|
||
// 频率限制:间隔限制(对齐 zibll 60 秒冷却 + 返回剩余时间)
|
||
$rate_limit = max(10, (int) ($config['sms_rate_limit'] ?? 60));
|
||
$rate_key = 'aksrt_sms_rate_' . md5($phone);
|
||
$rate_set = get_transient($rate_key);
|
||
if ($rate_set !== false) {
|
||
// 计算剩余等待时间
|
||
$rate_ts_key = 'aksrt_sms_rate_ts_' . md5($phone);
|
||
$sent_time = (int) get_transient($rate_ts_key);
|
||
$elapsed = $sent_time > 0 ? time() - $sent_time : $rate_limit;
|
||
$remaining = max(1, $rate_limit - $elapsed);
|
||
return [
|
||
'success' => false,
|
||
'message' => sprintf(__('请 %d 秒后再试', 'aksrt'), $remaining),
|
||
'remaining_time' => $remaining,
|
||
];
|
||
}
|
||
|
||
// 每日限制
|
||
$daily_limit = max(1, (int) ($config['sms_daily_limit'] ?? 10));
|
||
$daily_key = 'aksrt_sms_daily_' . md5($phone) . '_' . date('Ymd');
|
||
$daily_count = (int) get_transient($daily_key);
|
||
if ($daily_count >= $daily_limit) {
|
||
return ['success' => false, 'message' => __('今日发送次数已达上限', 'aksrt')];
|
||
}
|
||
|
||
$code_length = max(4, min(8, (int) ($config['sms_code_length'] ?? 6)));
|
||
$code = $this->generate_code($code_length);
|
||
$ttl = max(60, min(600, (int) ($config['sms_code_expire'] ?? 300)));
|
||
|
||
// 存储验证码(绑定手机号)
|
||
set_transient('aksrt_sms_code_' . md5($phone), $code, $ttl);
|
||
set_transient('aksrt_sms_phone_' . md5($phone), $phone, $ttl);
|
||
set_transient($rate_key, '1', $rate_limit);
|
||
set_transient('aksrt_sms_rate_ts_' . md5($phone), time(), $rate_limit);
|
||
set_transient($daily_key, $daily_count + 1, DAY_IN_SECONDS);
|
||
|
||
// 委托 provider 发送
|
||
$result = $provider->send_code($phone, $code, $config);
|
||
|
||
if (!empty($result['success'])) {
|
||
return [
|
||
'success' => true,
|
||
'message' => $result['message'] ?? __('验证码已发送', 'aksrt'),
|
||
'remaining_time' => $rate_limit,
|
||
];
|
||
}
|
||
|
||
// 发送失败:删除已存储的验证码,避免无效等待
|
||
delete_transient('aksrt_sms_code_' . md5($phone));
|
||
delete_transient('aksrt_sms_phone_' . md5($phone));
|
||
return ['success' => false, 'message' => $result['message'] ?? __('发送失败,请稍后重试', 'aksrt')];
|
||
}
|
||
|
||
/**
|
||
* 验证短信验证码(对齐 zibll:同时校验验证码和目标手机号)
|
||
*
|
||
* @param string $phone 手机号
|
||
* @param string $code 验证码
|
||
* @return array{success: bool, message: string}
|
||
*/
|
||
public function verify_code(string $phone, string $code): array {
|
||
// 错误频率限制检查
|
||
$error_check = $this->check_error_frequency('sms_verify_' . md5($phone));
|
||
if (!$error_check['allowed']) {
|
||
return [
|
||
'success' => false,
|
||
'message' => sprintf(__('验证失败次数过多,请 %d 秒后再试', 'aksrt'), $error_check['remaining']),
|
||
'remaining_time' => $error_check['remaining'],
|
||
];
|
||
}
|
||
|
||
$code_key = 'aksrt_sms_code_' . md5($phone);
|
||
$phone_key = 'aksrt_sms_phone_' . md5($phone);
|
||
|
||
$stored_code = get_transient($code_key);
|
||
$stored_phone = get_transient($phone_key);
|
||
|
||
// 验证码不存在或已过期
|
||
if ($stored_code === false) {
|
||
$this->record_error('sms_verify_' . md5($phone));
|
||
return ['success' => false, 'message' => __('验证码已过期或不存在', 'aksrt')];
|
||
}
|
||
|
||
// 验证码不匹配
|
||
if (!hash_equals((string) $stored_code, $code)) {
|
||
$this->record_error('sms_verify_' . md5($phone));
|
||
return ['success' => false, 'message' => __('验证码不正确', 'aksrt')];
|
||
}
|
||
|
||
// 目标手机号绑定校验
|
||
if ($stored_phone !== false && (string) $stored_phone !== $phone) {
|
||
$this->record_error('sms_verify_' . md5($phone));
|
||
return ['success' => false, 'message' => __('手机号不匹配', 'aksrt')];
|
||
}
|
||
|
||
// 验证成功:清除验证码和错误计数
|
||
delete_transient($code_key);
|
||
delete_transient($phone_key);
|
||
$this->reset_error_frequency('sms_verify_' . md5($phone));
|
||
|
||
return ['success' => true, 'message' => __('验证成功', 'aksrt')];
|
||
}
|
||
|
||
/**
|
||
* 兼容旧接口:verify_code 返回 bool
|
||
*/
|
||
public function verify_code_bool(string $phone, string $code): bool {
|
||
$result = $this->verify_code($phone, $code);
|
||
return !empty($result['success']);
|
||
}
|
||
|
||
/**
|
||
* 生成验证码
|
||
*/
|
||
private function generate_code(int $length = 6): string {
|
||
$max = (int) str_pad('9', $length, '9');
|
||
return str_pad((string) random_int(0, $max), $length, '0', STR_PAD_LEFT);
|
||
}
|
||
|
||
/* ------------------------------------------------------------------
|
||
* 错误频率限制(对齐 zibll 的 error_frequency_limit)
|
||
* ----------------------------------------------------------------*/
|
||
|
||
/**
|
||
* 检查是否被频率限制
|
||
*
|
||
* @return array{allowed: bool, remaining: int}
|
||
*/
|
||
private function check_error_frequency(string $key): array {
|
||
$count_key = 'aksrt_sms_err_' . $key;
|
||
$time_key = 'aksrt_sms_err_ts_' . $key;
|
||
|
||
$count = (int) get_transient($count_key);
|
||
$last = (int) get_transient($time_key);
|
||
|
||
if ($count <= 0) {
|
||
return ['allowed' => true, 'remaining' => 0];
|
||
}
|
||
|
||
// 超过 10 次错误:3 分钟冷却
|
||
if ($count >= 10) {
|
||
$cooldown = 180;
|
||
$elapsed = time() - $last;
|
||
if ($elapsed < $cooldown) {
|
||
return ['allowed' => false, 'remaining' => $cooldown - $elapsed];
|
||
}
|
||
// 冷却结束,重置
|
||
$this->reset_error_frequency($key);
|
||
return ['allowed' => true, 'remaining' => 0];
|
||
}
|
||
|
||
// 超过 5 次错误:30 秒冷却
|
||
if ($count >= 5) {
|
||
$cooldown = 30;
|
||
$elapsed = time() - $last;
|
||
if ($elapsed < $cooldown) {
|
||
return ['allowed' => false, 'remaining' => $cooldown - $elapsed];
|
||
}
|
||
}
|
||
|
||
return ['allowed' => true, 'remaining' => 0];
|
||
}
|
||
|
||
/**
|
||
* 记录一次错误
|
||
*/
|
||
private function record_error(string $key): void {
|
||
$count_key = 'aksrt_sms_err_' . $key;
|
||
$time_key = 'aksrt_sms_err_ts_' . $key;
|
||
|
||
$count = (int) get_transient($count_key);
|
||
set_transient($count_key, $count + 1, HOUR_IN_SECONDS);
|
||
set_transient($time_key, time(), HOUR_IN_SECONDS);
|
||
}
|
||
|
||
/**
|
||
* 重置错误计数
|
||
*/
|
||
private function reset_error_frequency(string $key): void {
|
||
delete_transient('aksrt_sms_err_' . $key);
|
||
delete_transient('aksrt_sms_err_ts_' . $key);
|
||
}
|
||
|
||
/* ------------------------------------------------------------------
|
||
* AJAX Handlers
|
||
* ----------------------------------------------------------------*/
|
||
|
||
/**
|
||
* 手机号格式校验(对齐 zibll:支持中国号和国际号)
|
||
*/
|
||
private function validate_phone(string $phone): bool {
|
||
$config = $this->get_provider_config();
|
||
$limit_ch = !isset($config['sms_number_limit_ch']) || !empty($config['sms_number_limit_ch']);
|
||
|
||
if ($limit_ch) {
|
||
// 仅中国大陆手机号
|
||
return (bool) preg_match('/^1[3-9]\d{9}$/', $phone);
|
||
}
|
||
|
||
// 国际手机号:5 位以上数字,可选 + 前缀
|
||
return (bool) preg_match('/^(?:\d{5,}|\+\d{6,})$/', $phone);
|
||
}
|
||
|
||
/**
|
||
* AJAX 发送验证码
|
||
*/
|
||
public function ajax_send_code(): void {
|
||
check_ajax_referer('aksrt_sms', '_nonce');
|
||
|
||
$phone = sanitize_text_field((string) ($_POST['phone'] ?? ''));
|
||
if (!$this->validate_phone($phone)) {
|
||
wp_send_json_error(['message' => __('手机号格式不正确', 'aksrt')]);
|
||
}
|
||
|
||
$scene = sanitize_key((string) ($_POST['scene'] ?? 'register'));
|
||
if (!array_key_exists($scene, function_exists('aksrt_captcha_scenes') ? aksrt_captcha_scenes() : [])) {
|
||
$scene = 'register';
|
||
}
|
||
if (function_exists('aksrt_require_captcha')) {
|
||
$captcha_result = aksrt_require_captcha($scene, wp_unslash($_POST));
|
||
if ($captcha_result !== true) {
|
||
wp_send_json_error(['message' => $captcha_result ?: __('人机验证失败', 'aksrt')]);
|
||
}
|
||
}
|
||
|
||
$result = $this->send_code($phone);
|
||
|
||
if ($result['success']) {
|
||
wp_send_json_success([
|
||
'message' => $result['message'],
|
||
'remaining_time' => $result['remaining_time'] ?? 60,
|
||
]);
|
||
}
|
||
|
||
$error_data = ['message' => $result['message']];
|
||
if (isset($result['remaining_time'])) {
|
||
$error_data['remaining_time'] = $result['remaining_time'];
|
||
}
|
||
wp_send_json_error($error_data);
|
||
}
|
||
|
||
/**
|
||
* AJAX: Test SMS provider (admin only).
|
||
*/
|
||
public function ajax_test_provider(): void {
|
||
if (!current_user_can('manage_options')) {
|
||
wp_send_json_error(['message' => __('权限不足', 'aksrt')]);
|
||
}
|
||
|
||
$phone = sanitize_text_field((string) ($_POST['phone'] ?? ''));
|
||
if (!preg_match('/^1[3-9]\d{9}$/', $phone)) {
|
||
wp_send_json_error(['message' => __('手机号格式不正确', 'aksrt')]);
|
||
}
|
||
|
||
$result = $this->test_provider($phone);
|
||
wp_send_json($result);
|
||
}
|
||
|
||
/**
|
||
* AJAX: Get SMS provider status (admin only).
|
||
*/
|
||
public function ajax_provider_status(): void {
|
||
if (!current_user_can('manage_options')) {
|
||
wp_send_json_error(['message' => __('权限不足', 'aksrt')]);
|
||
}
|
||
|
||
wp_send_json_success($this->get_provider_status());
|
||
}
|
||
|
||
/**
|
||
* 获取 provider 真实运行时状态
|
||
*/
|
||
public function get_provider_status(): array {
|
||
$config = $this->get_provider_config();
|
||
$provider_key = sanitize_key((string) ($config['sms_provider'] ?? ''));
|
||
|
||
$status = [
|
||
'enabled' => !empty($config['sms_enabled']),
|
||
'provider' => $provider_key ?: 'none',
|
||
'configured' => false,
|
||
'available' => false,
|
||
'verified' => false,
|
||
'transport' => '',
|
||
'missing_fields' => [],
|
||
'code_length' => max(4, min(8, (int) ($config['sms_code_length'] ?? 6))),
|
||
'code_expire' => max(60, min(600, (int) ($config['sms_code_expire'] ?? 300))),
|
||
'rate_limit' => max(10, (int) ($config['sms_rate_limit'] ?? 60)),
|
||
'daily_limit' => max(1, (int) ($config['sms_daily_limit'] ?? 10)),
|
||
'notify_bind' => !empty($config['sms_notify_bind']),
|
||
'notify_login' => !empty($config['sms_notify_login']),
|
||
];
|
||
|
||
$provider = AKSRT_SMS_Provider_Factory::get($provider_key);
|
||
if ($provider !== null) {
|
||
$provider_status = $provider->get_status($config);
|
||
$status['configured'] = !empty($provider_status['configured']);
|
||
$status['available'] = !empty($provider_status['available']);
|
||
$status['verified'] = !empty($provider_status['verified']);
|
||
$status['transport'] = (string) ($provider_status['transport'] ?? '');
|
||
$status['missing_fields'] = (array) ($provider_status['missing_fields'] ?? []);
|
||
}
|
||
|
||
return $status;
|
||
}
|
||
|
||
/**
|
||
* Test provider connectivity (admin only).
|
||
*/
|
||
public function test_provider(string $phone): array {
|
||
$config = $this->get_provider_config();
|
||
if (empty($config['sms_enabled'])) {
|
||
return ['success' => false, 'message' => __('短信服务未启用', 'aksrt')];
|
||
}
|
||
|
||
$provider_key = sanitize_key((string) ($config['sms_provider'] ?? ''));
|
||
if ($provider_key === '') {
|
||
return ['success' => false, 'message' => __('未配置短信供应商', 'aksrt')];
|
||
}
|
||
|
||
$provider = AKSRT_SMS_Provider_Factory::get($provider_key);
|
||
if ($provider === null) {
|
||
return ['success' => false, 'message' => sprintf(__('短信供应商 "%s" 不可用', 'aksrt'), $provider_key)];
|
||
}
|
||
|
||
$missing = $provider->validate_config($config);
|
||
if (!empty($missing)) {
|
||
return ['success' => false, 'message' => sprintf(__('缺少配置项: %s', 'aksrt'), implode(', ', $missing))];
|
||
}
|
||
|
||
return $this->send_code($phone);
|
||
}
|
||
}
|