feat(security): 新增网站安全扩展模块并支持修改手机脱敏验证
This commit is contained in:
@@ -17,6 +17,7 @@ class SchemaExtensions {
|
||||
'ip' => SchemaExtensionsIP::build(),
|
||||
'audit' => SchemaExtensionsAudit::build(),
|
||||
'push' => SchemaExtensionsPush::build(),
|
||||
'security' => SchemaExtensionsSecurity::build(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Admin\Schema;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
class SchemaExtensionsSecurity {
|
||||
|
||||
public static function build(): array {
|
||||
return [
|
||||
'key' => 'security',
|
||||
'title' => '网站安全',
|
||||
'version' => '1.0.0',
|
||||
'option_key' => 'aksrt_settings',
|
||||
'groups' => [
|
||||
['key' => 'brush_post', 'title' => '[防刷机制]发布限制', 'description' => '限制单个用户发布文章、帖子、板块的总数量(为0则不限制)', 'fields' => [
|
||||
['key' => 'security_brush_post_10min', 'label' => '10分钟内最多发布 (篇)', 'type' => 'number', 'default' => 20, 'min' => 0, 'max' => 200],
|
||||
['key' => 'security_brush_post_day', 'label' => '一天内最多发布 (篇)', 'type' => 'number', 'default' => 50, 'min' => 0, 'max' => 2000],
|
||||
]],
|
||||
['key' => 'brush_comment', 'title' => '[防刷机制]评论限制', 'description' => '限制单个用户发布评论的总数量(为0则不限制)', 'fields' => [
|
||||
['key' => 'security_brush_comment_10min', 'label' => '10分钟内最多评论 (条)', 'type' => 'number', 'default' => 20, 'min' => 0, 'max' => 200],
|
||||
['key' => 'security_brush_comment_day', 'label' => '一天内最多评论 (条)', 'type' => 'number', 'default' => 100, 'min' => 0, 'max' => 2000, 'help' => '如果启用了免登陆可评论,则根据用户IP地址判断'],
|
||||
]],
|
||||
['key' => 'brush_upload', 'title' => '[防刷机制]上传限制', 'description' => '限制单个用户上传内容的总数量(为0则不限制)。防刷机制主要为了防止用户恶意多次提交内容,请根据网站的用户量合理设置,此机制不会限制管理员', 'fields' => [
|
||||
['key' => 'security_brush_upload_10min', 'label' => '10分钟内最多上传 (个)', 'type' => 'number', 'default' => 50, 'min' => 0, 'max' => 200],
|
||||
['key' => 'security_brush_upload_day', 'label' => '一天内最多上传 (个)', 'type' => 'number', 'default' => 200, 'min' => 0, 'max' => 2000],
|
||||
]],
|
||||
['key' => 'brush_register', 'title' => '[防刷机制]注册限制', 'description' => '限制每个IP地址用户注册数量,此项无法判断管理员身份,所以管理员也会受到限制', 'fields' => [
|
||||
['key' => 'security_brush_register_10min', 'label' => '10分钟内最多注册 (个)', 'type' => 'number', 'default' => 20, 'min' => 0, 'max' => 200],
|
||||
['key' => 'security_brush_register_day', 'label' => '一天内最多注册 (个)', 'type' => 'number', 'default' => 100, 'min' => 0, 'max' => 2000, 'help' => '如果您的服务器不能正确的获取用户真实IP地址,请关闭此项(0为不限制)'],
|
||||
]],
|
||||
['key' => 'brush_order', 'title' => '[防刷机制]下单限制', 'description' => '限制每个用户下单数量,未登录用户按IP地址判断', 'fields' => [
|
||||
['key' => 'security_brush_order_10min', 'label' => '10分钟内最多下单 (个订单)', 'type' => 'number', 'default' => 30, 'min' => 0, 'max' => 200],
|
||||
['key' => 'security_brush_order_day', 'label' => '一天内最多下单 (个订单)', 'type' => 'number', 'default' => 100, 'min' => 0, 'max' => 2000, 'help' => '如果您的服务器不能正确的获取用户真实IP地址,请关闭此项(0为不限制)'],
|
||||
]],
|
||||
['key' => 'brush_captcha', 'title' => '[防刷机制]验证码发送限制', 'description' => '限制验证码邮件/短信的发送频率,防止恶意程序批量轰炸特定邮箱或手机号', 'fields' => [
|
||||
['key' => 'security_captcha_send_cooldown', 'label' => '同一收件人/IP发送冷却 (秒)', 'type' => 'number', 'default' => 60, 'min' => 0, 'max' => 600, 'help' => '同一邮箱或手机号、同一IP地址发送验证码后,需间隔此时间才可再次发送(0为不限制)'],
|
||||
['key' => 'security_captcha_to_hour', 'label' => '同一收件人1小时内最多 (次)', 'type' => 'number', 'default' => 15, 'min' => 0, 'max' => 500],
|
||||
['key' => 'security_captcha_to_day', 'label' => '同一收件人一天内最多 (次)', 'type' => 'number', 'default' => 30, 'min' => 0, 'max' => 1000],
|
||||
['key' => 'security_captcha_ip_10min', 'label' => '同一IP 10分钟内最多 (次)', 'type' => 'number', 'default' => 20, 'min' => 0, 'max' => 500],
|
||||
['key' => 'security_captcha_ip_day', 'label' => '同一IP一天内最多 (次)', 'type' => 'number', 'default' => 50, 'min' => 0, 'max' => 2000, 'help' => '如果您的服务器不能正确的获取用户真实IP地址,请关闭此项(0为不限制)'],
|
||||
]],
|
||||
['key' => 'harden', 'title' => '安全加固', 'fields' => [
|
||||
['key' => 'security_error_frequency_limit', 'label' => '错误频率限制', 'type' => 'switch', 'default' => true, 'help' => '用户进行密码、邮箱、手机号、验证码等验证时,验证错误次数超过5次后限制30秒验证一次,错误超过10次后,限制3分钟验证一次,有效防止暴力破解'],
|
||||
['key' => 'security_disabled_pingback', 'label' => '防pingback攻击', 'type' => 'switch', 'default' => true],
|
||||
['key' => 'security_disabled_xmlrpc', 'label' => '禁用XML-RPC', 'type' => 'switch', 'default' => true, 'help' => '如果无需使用xmlrpc功能,建议直接删除WordPress目录下的xmlrpc.php文件'],
|
||||
['key' => 'security_user_disable_admin', 'label' => '非管理员禁止进入后台', 'type' => 'switch', 'default' => true, 'help' => '为了安全,如非必要请勿允许非管理员进入后台'],
|
||||
['key' => 'close_attachment_page', 'label' => '非管理员屏蔽附件页面', 'type' => 'switch', 'default' => true, 'help' => '非管理员前台不显示附件(图片、文件等)页面,直接显示404,能有效提高文件的安全性'],
|
||||
]],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -137,7 +137,6 @@ class SchemaPermissions {
|
||||
['label' => '时间 + 原文件名', 'value' => 'time'],
|
||||
['label' => '随机字符串', 'value' => 'random'],
|
||||
]],
|
||||
['key' => 'close_attachment_page', 'label' => '非管理员屏蔽附件页面', 'type' => 'switch', 'default' => true, 'help' => '开启后非管理员访问附件(图片、文件等)页面将直接显示 404,可有效提高文件的安全性。'],
|
||||
]],
|
||||
],
|
||||
],
|
||||
|
||||
@@ -205,6 +205,11 @@ class UserController extends ControllerBase {
|
||||
'callback' => [$this, 'user_phone_code'],
|
||||
'permission_callback' => static function () { return is_user_logged_in(); },
|
||||
]);
|
||||
register_rest_route(self::API_NAMESPACE, '/user/phone-match', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [$this, 'user_phone_match'],
|
||||
'permission_callback' => static function () { return is_user_logged_in(); },
|
||||
]);
|
||||
register_rest_route(self::API_NAMESPACE, '/user/phone-verify', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [$this, 'user_verify_phone'],
|
||||
|
||||
@@ -196,6 +196,14 @@ trait UserControllerAuthPhoneOperations {
|
||||
return $this->success(['message' => $generic, 'countdown' => (int) ($result['remaining_time'] ?? 60)]);
|
||||
}
|
||||
|
||||
// [防刷机制]验证码发送限制(短信通道由 SMS::send_code 内部接入)。
|
||||
if (class_exists('\AKSRT\Extensions\SendLimit')) {
|
||||
$limit = \AKSRT\Extensions\SendLimit::check($target);
|
||||
if (empty($limit['ok'])) {
|
||||
return $this->error((string) $limit['message'], 429);
|
||||
}
|
||||
}
|
||||
|
||||
$code = (string) random_int(100000, 999999);
|
||||
$scene_event = [
|
||||
'login' => 'login_code',
|
||||
@@ -221,8 +229,9 @@ trait UserControllerAuthPhoneOperations {
|
||||
}
|
||||
return $this->error(__('验证码发送失败,请稍后再试', 'aksrt'), 500);
|
||||
}
|
||||
$cooldown = class_exists('\AKSRT\Extensions\SendLimit') ? (int) \AKSRT\Extensions\SendLimit::record($target) : 0;
|
||||
$this->auth_store_code($scene, $target, $code);
|
||||
return $this->success(['message' => $generic, 'countdown' => 60]);
|
||||
return $this->success(['message' => $generic, 'countdown' => $cooldown > 0 ? $cooldown : 60]);
|
||||
|
||||
}
|
||||
|
||||
@@ -465,13 +474,35 @@ trait UserControllerAuthPhoneOperations {
|
||||
$settings = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_all() : [];
|
||||
if (empty($settings['bind_phone'])) return $this->error(__('手机号绑定功能暂未开启', 'aksrt'), 403);
|
||||
$stage = sanitize_key((string) ($payload['stage'] ?? 'bind_new_phone'));
|
||||
$phone = sanitize_text_field((string) ($payload['phone'] ?? ''));
|
||||
if (!\AKSRT\Sms\SMS::is_phonenumber($phone)) return $this->error(__('手机号格式不正确', 'aksrt'));
|
||||
$user_id = get_current_user_id();
|
||||
$current_phone = (string) get_user_meta($user_id, 'phone_number', true);
|
||||
|
||||
if ($stage === 'reset_old_phone') {
|
||||
// 前端在修改已通过校验的中间位后调用:销毁已发放的老号验证码状态。
|
||||
if ($current_phone !== '') {
|
||||
delete_transient($this->auth_code_key('account_verify_old_phone_' . $user_id, $current_phone));
|
||||
}
|
||||
return $this->success(['message' => __('已取消本次验证', 'aksrt')]);
|
||||
}
|
||||
|
||||
if ($stage === 'verify_old_phone') {
|
||||
if ($current_phone === '' || !hash_equals($current_phone, $phone)) return $this->error(__('当前绑定手机号已变化,请刷新页面重试', 'aksrt'), 409);
|
||||
// 老号验证只接收脱敏中间位,发送目标恒为服务端绑定号,
|
||||
// 避免客户端提交任意号码导致向第三方手机号发码。
|
||||
if ($current_phone === '') return $this->error(__('当前绑定手机号已变化,请刷新页面重试', 'aksrt'), 409);
|
||||
$parts = function_exists('aksrt_phone_mask_parts') ? aksrt_phone_mask_parts($current_phone) : null;
|
||||
if ($parts !== null) {
|
||||
$middle = sanitize_text_field((string) ($payload['middle'] ?? ''));
|
||||
if (!$this->auth_middle_matches($parts, $middle)) {
|
||||
if (class_exists('\AKSRT\Extensions\SecurityHardening')) {
|
||||
\AKSRT\Extensions\SecurityHardening::error_frequency_record();
|
||||
}
|
||||
return $this->error(__('填写的号码与绑定手机号不一致', 'aksrt'), 400);
|
||||
}
|
||||
}
|
||||
$phone = $current_phone;
|
||||
} elseif ($stage === 'bind_new_phone') {
|
||||
$phone = sanitize_text_field((string) ($payload['phone'] ?? ''));
|
||||
if (!\AKSRT\Sms\SMS::is_phonenumber($phone)) return $this->error(__('手机号格式不正确', 'aksrt'));
|
||||
if ($current_phone !== '' && !hash_equals($current_phone, $phone)) {
|
||||
$proof = get_transient('aksrt_account_phone_old_' . $user_id);
|
||||
if (!is_array($proof) || !hash_equals($current_phone, (string) ($proof['phone'] ?? ''))) return $this->error(__('请先验证老手机号', 'aksrt'), 403);
|
||||
@@ -484,9 +515,11 @@ trait UserControllerAuthPhoneOperations {
|
||||
}
|
||||
$captcha_error = $this->auth_require_captcha('bind_phone', $payload);
|
||||
if ($captcha_error) return $captcha_error;
|
||||
if (class_exists('\AKSRT\Extensions\SecurityHardening')) {
|
||||
$wait = \AKSRT\Extensions\SecurityHardening::error_frequency_wait();
|
||||
if ($wait > 0) return $this->error(sprintf(__('验证错误次数过多,请 %d 秒后再试', 'aksrt'), $wait), 429);
|
||||
}
|
||||
if (!class_exists('\AKSRT\Sms\SMS')) return $this->error(__('短信服务不可用', 'aksrt'), 503);
|
||||
$rate_subject = $stage . '|' . $user_id . '|' . $phone;
|
||||
if ($this->auth_rate_limited('account-phone-code', $rate_subject, 5, HOUR_IN_SECONDS)) return $this->error(__('验证码请求过于频繁,请稍后再试', 'aksrt'), 429);
|
||||
$code = (string) random_int(100000, 999999);
|
||||
$result = \AKSRT\Sms\SMS::get_instance()->send_code($phone, $code);
|
||||
if (empty($result['success'])) return $this->error((string) ($result['message'] ?? __('发送失败', 'aksrt')));
|
||||
@@ -494,6 +527,53 @@ trait UserControllerAuthPhoneOperations {
|
||||
return $this->success(['message' => __('验证码短信已发送', 'aksrt'), 'countdown' => (int) ($result['remaining_time'] ?? 60)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验客户端提交的脱敏中间位是否与绑定号一致(不发短信)。
|
||||
*/
|
||||
public function user_phone_match(\WP_REST_Request $request): \WP_REST_Response {
|
||||
$payload = $this->auth_payload($request);
|
||||
if (!$this->auth_verify_rest_nonce($request, $payload)) return $this->error(__('安全验证失败', 'aksrt'), 403);
|
||||
$settings = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_all() : [];
|
||||
if (empty($settings['bind_phone'])) return $this->error(__('手机号绑定功能暂未开启', 'aksrt'), 403);
|
||||
$user_id = get_current_user_id();
|
||||
$current_phone = (string) get_user_meta($user_id, 'phone_number', true);
|
||||
$parts = function_exists('aksrt_phone_mask_parts') ? aksrt_phone_mask_parts($current_phone) : null;
|
||||
if ($parts === null) return $this->error(__('当前绑定手机号不支持此验证方式', 'aksrt'), 409);
|
||||
|
||||
// 中间位是可枚举面:错误频率限制 + 每用户/IP 每小时尝试上限双保险。
|
||||
if (class_exists('\AKSRT\Extensions\SecurityHardening')) {
|
||||
$wait = \AKSRT\Extensions\SecurityHardening::error_frequency_wait();
|
||||
if ($wait > 0) return $this->error(sprintf(__('验证错误次数过多,请 %d 秒后再试', 'aksrt'), $wait), 429);
|
||||
}
|
||||
$ip = function_exists('aksrt_get_client_ip') ? aksrt_get_client_ip() : (string) ($_SERVER['REMOTE_ADDR'] ?? '');
|
||||
if ($this->auth_rate_limited('phone-match', (string) $user_id . '|' . $ip, 20, HOUR_IN_SECONDS)) {
|
||||
return $this->error(__('尝试次数过多,请稍后再试', 'aksrt'), 429);
|
||||
}
|
||||
|
||||
$middle = sanitize_text_field((string) ($payload['middle'] ?? ''));
|
||||
if (!$this->auth_middle_matches($parts, $middle)) {
|
||||
if (class_exists('\AKSRT\Extensions\SecurityHardening')) {
|
||||
\AKSRT\Extensions\SecurityHardening::error_frequency_record();
|
||||
}
|
||||
return $this->error(__('填写的号码与绑定手机号不一致', 'aksrt'), 400);
|
||||
}
|
||||
if (class_exists('\AKSRT\Extensions\SecurityHardening')) {
|
||||
\AKSRT\Extensions\SecurityHardening::error_frequency_clear();
|
||||
}
|
||||
return $this->success(['match' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 比对脱敏中间位:长度必须与隐藏位数一致,按位恒时比较。
|
||||
*/
|
||||
private function auth_middle_matches(array $parts, string $middle): bool {
|
||||
$middle = preg_replace('/\D+/', '', $middle);
|
||||
if ($middle === '' || strlen($middle) !== (int) $parts['hidden_length']) {
|
||||
return false;
|
||||
}
|
||||
return hash_equals((string) $parts['hidden'], $middle);
|
||||
}
|
||||
|
||||
public function user_verify_phone(\WP_REST_Request $request): \WP_REST_Response {
|
||||
$payload = $this->auth_payload($request);
|
||||
if (!$this->auth_verify_rest_nonce($request, $payload)) return $this->error(__('安全验证失败', 'aksrt'), 403);
|
||||
@@ -503,10 +583,17 @@ trait UserControllerAuthPhoneOperations {
|
||||
if ($captcha_error) return $captcha_error;
|
||||
$user_id = get_current_user_id();
|
||||
$current_phone = (string) get_user_meta($user_id, 'phone_number', true);
|
||||
$phone = sanitize_text_field((string) ($payload['phone'] ?? ''));
|
||||
$code = sanitize_text_field((string) ($payload['code'] ?? ''));
|
||||
if ($current_phone === '' || !hash_equals($current_phone, $phone)) return $this->error(__('当前绑定手机号已变化,请刷新页面重试', 'aksrt'), 409);
|
||||
if (!$this->auth_consume_code('account_verify_old_phone_' . $user_id, $current_phone, $code)) return $this->error(__('验证码错误或已过期', 'aksrt'));
|
||||
if ($current_phone === '') return $this->error(__('当前绑定手机号已变化,请刷新页面重试', 'aksrt'), 409);
|
||||
if (!$this->auth_consume_code('account_verify_old_phone_' . $user_id, $current_phone, $code)) {
|
||||
if (class_exists('\AKSRT\Extensions\SecurityHardening')) {
|
||||
\AKSRT\Extensions\SecurityHardening::error_frequency_record();
|
||||
}
|
||||
return $this->error(__('验证码错误或已过期', 'aksrt'));
|
||||
}
|
||||
if (class_exists('\AKSRT\Extensions\SecurityHardening')) {
|
||||
\AKSRT\Extensions\SecurityHardening::error_frequency_clear();
|
||||
}
|
||||
set_transient('aksrt_account_phone_old_' . $user_id, ['phone' => $current_phone], 30 * MINUTE_IN_SECONDS);
|
||||
return $this->success(['message' => __('手机号验证成功', 'aksrt')]);
|
||||
}
|
||||
@@ -533,10 +620,18 @@ trait UserControllerAuthPhoneOperations {
|
||||
$agreement = function_exists('aksrt_get_auth_agreement_view') ? aksrt_get_auth_agreement_view() : ['enabled' => false, 'required' => false];
|
||||
if (!empty($agreement['required']) && empty($payload['agree_terms'])) return $this->error(__('请先同意相关协议', 'aksrt'), 400);
|
||||
$code_scene = 'account_bind_new_phone_' . $user_id;
|
||||
if (!$this->auth_consume_code($code_scene, $phone, $code, false)) return $this->error(__('验证码错误或已过期', 'aksrt'));
|
||||
if (!$this->auth_consume_code($code_scene, $phone, $code, false)) {
|
||||
if (class_exists('\AKSRT\Extensions\SecurityHardening')) {
|
||||
\AKSRT\Extensions\SecurityHardening::error_frequency_record();
|
||||
}
|
||||
return $this->error(__('验证码错误或已过期', 'aksrt'));
|
||||
}
|
||||
if (!class_exists('\AKSRT\Auth\Service')) return $this->error(__('认证服务不可用', 'aksrt'), 500);
|
||||
$result = \AKSRT\Auth\Service::bind_phone($payload, true);
|
||||
if (empty($result['success'])) return $this->error((string) ($result['error'] ?? __('绑定失败', 'aksrt')));
|
||||
if (class_exists('\AKSRT\Extensions\SecurityHardening')) {
|
||||
\AKSRT\Extensions\SecurityHardening::error_frequency_clear();
|
||||
}
|
||||
delete_transient($this->auth_code_key($code_scene, $phone));
|
||||
delete_transient('aksrt_account_phone_old_' . $user_id);
|
||||
return $this->success(['message' => $result['message'] ?? __('绑定成功', 'aksrt')]);
|
||||
|
||||
@@ -45,6 +45,7 @@ class UploadLimits {
|
||||
public function init(): void {
|
||||
add_filter('upload_mimes', [$this, 'filter_upload_mimes']);
|
||||
add_filter('getimagesize_mimes_to_exts', [$this, 'filter_image_mimes_to_exts']);
|
||||
add_filter('wp_handle_upload_prefilter', [$this, 'brush_limit_upload'], 90);
|
||||
add_filter('wp_handle_upload_prefilter', [$this, 'rename_upload_file'], 99);
|
||||
add_action('template_redirect', [$this, 'close_attachment_page']);
|
||||
}
|
||||
@@ -305,6 +306,27 @@ class UploadLimits {
|
||||
return $file;
|
||||
}
|
||||
|
||||
/**
|
||||
* [防刷机制]上传限制(后台「扩展&增强 → 网站安全」),管理员不受限。
|
||||
*/
|
||||
public function brush_limit_upload(array $file): array {
|
||||
if (!empty($file['error']) || !class_exists('\AKSRT\Extensions\SecurityHardening')) {
|
||||
return $file;
|
||||
}
|
||||
if (current_user_can('manage_options')) {
|
||||
return $file;
|
||||
}
|
||||
|
||||
$security = \AKSRT\Settings\ExtensionRuntime::get_security_settings();
|
||||
$user_id = get_current_user_id();
|
||||
$subject = $user_id > 0 ? (string) $user_id : (function_exists('aksrt_get_client_ip') ? aksrt_get_client_ip() : (string) ($_SERVER['REMOTE_ADDR'] ?? ''));
|
||||
$brush = \AKSRT\Extensions\SecurityHardening::brush_allow('upload', $subject, (int) $security['security_brush_upload_10min'], (int) $security['security_brush_upload_day']);
|
||||
if ($brush !== true) {
|
||||
$file['error'] = (string) $brush;
|
||||
}
|
||||
return $file;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide attachment pages from non-admins (returns 404 instead).
|
||||
*/
|
||||
|
||||
@@ -124,6 +124,25 @@ class Moderation {
|
||||
}
|
||||
}
|
||||
|
||||
// [防刷机制]评论限制(后台「扩展&增强 → 网站安全」),管理员不受限;
|
||||
// 未登录用户按 IP 判断,与下方审核限流(间隔/每小时)互为补充。
|
||||
if (!current_user_can('manage_options') && class_exists('\AKSRT\Extensions\SecurityHardening')) {
|
||||
$security = \AKSRT\Settings\ExtensionRuntime::get_security_settings();
|
||||
$brush = \AKSRT\Extensions\SecurityHardening::brush_allow(
|
||||
'comment',
|
||||
$actor > 0 ? (string) $actor : $this->get_client_ip(),
|
||||
(int) $security['security_brush_comment_10min'],
|
||||
(int) $security['security_brush_comment_day']
|
||||
);
|
||||
if ($brush !== true) {
|
||||
wp_die(
|
||||
'<p>' . esc_html((string) $brush) . '</p>',
|
||||
esc_html__('评论提交失败', 'aksrt'),
|
||||
['response' => 429]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$config = $this->get_config();
|
||||
|
||||
if (empty($config['enabled'])) {
|
||||
|
||||
@@ -140,6 +140,16 @@ trait ServicePart1a {
|
||||
}
|
||||
}
|
||||
|
||||
// [防刷机制]注册限制(后台「扩展&增强 → 网站安全」),按 IP 判断且不豁免管理员。
|
||||
if (class_exists('\AKSRT\Extensions\SecurityHardening')) {
|
||||
$security = \AKSRT\Settings\ExtensionRuntime::get_security_settings();
|
||||
$brush_ip = function_exists('aksrt_get_client_ip') ? aksrt_get_client_ip() : (string) ($_SERVER['REMOTE_ADDR'] ?? '');
|
||||
$brush = \AKSRT\Extensions\SecurityHardening::brush_allow('register', $brush_ip, (int) $security['security_brush_register_10min'], (int) $security['security_brush_register_day']);
|
||||
if ($brush !== true) {
|
||||
return ['success' => false, 'error' => (string) $brush];
|
||||
}
|
||||
}
|
||||
|
||||
$user_id = wp_create_user($user_login, $password, $user_email);
|
||||
if (is_wp_error($user_id)) {
|
||||
if ($ref_service) {
|
||||
|
||||
@@ -114,6 +114,16 @@ trait ServicePart1b {
|
||||
}
|
||||
}
|
||||
|
||||
// [防刷机制]注册限制(后台「扩展&增强 → 网站安全」),按 IP 判断且不豁免管理员。
|
||||
if (class_exists('\AKSRT\Extensions\SecurityHardening')) {
|
||||
$security = \AKSRT\Settings\ExtensionRuntime::get_security_settings();
|
||||
$brush_ip = function_exists('aksrt_get_client_ip') ? aksrt_get_client_ip() : (string) ($_SERVER['REMOTE_ADDR'] ?? '');
|
||||
$brush = \AKSRT\Extensions\SecurityHardening::brush_allow('register', $brush_ip, (int) $security['security_brush_register_10min'], (int) $security['security_brush_register_day']);
|
||||
if ($brush !== true) {
|
||||
return ['success' => false, 'error' => (string) $brush];
|
||||
}
|
||||
}
|
||||
|
||||
// 创建用户
|
||||
$user_id = wp_create_user($username, $pass, '');
|
||||
if (is_wp_error($user_id)) {
|
||||
|
||||
@@ -121,6 +121,18 @@ trait FrontSubmitSave {
|
||||
$author_id = (int) $settings['default_user_id'];
|
||||
}
|
||||
|
||||
// [防刷机制]发布限制(后台「扩展&增强 → 网站安全」),管理员不受限。
|
||||
if (!$is_edit && !current_user_can('manage_options') && class_exists('\AKSRT\Extensions\SecurityHardening')) {
|
||||
$security = \AKSRT\Settings\ExtensionRuntime::get_security_settings();
|
||||
$brush_subject = $is_guest
|
||||
? (string) (function_exists('aksrt_get_client_ip') ? aksrt_get_client_ip() : ($_SERVER['REMOTE_ADDR'] ?? ''))
|
||||
: (string) $user_id;
|
||||
$brush = \AKSRT\Extensions\SecurityHardening::brush_allow('post', $brush_subject, (int) $security['security_brush_post_10min'], (int) $security['security_brush_post_day']);
|
||||
if ($brush !== true) {
|
||||
return ['success' => false, 'message' => (string) $brush];
|
||||
}
|
||||
}
|
||||
|
||||
$status = 'draft';
|
||||
if (!$is_draft) {
|
||||
$status = $this->resolve_publish_status($user_id);
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
/**
|
||||
* 网站安全:安全加固开关、内容防刷限制与验证错误频率限制。
|
||||
*
|
||||
* 对应后台「扩展&增强 → 网站安全」设置(ExtensionRuntime::get_security_settings()),
|
||||
* 行为对齐参考主题 zib-theme.php / action/function.php 的安全套件。
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Extensions;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
class SecurityHardening {
|
||||
|
||||
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 {
|
||||
$settings = \AKSRT\Settings\ExtensionRuntime::get_security_settings();
|
||||
|
||||
if (!empty($settings['security_disabled_xmlrpc'])) {
|
||||
add_filter('xmlrpc_enabled', '__return_false');
|
||||
add_filter('xmlrpc_methods', '__return_empty_array');
|
||||
}
|
||||
|
||||
if (!empty($settings['security_disabled_pingback'])) {
|
||||
add_action('pre_ping', [$this, 'disable_self_pingback']);
|
||||
}
|
||||
|
||||
if (!empty($settings['security_user_disable_admin'])) {
|
||||
add_action('admin_init', [$this, 'restrict_admin_access']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 防 pingback 攻击:移除指向自己站点的 pingback。
|
||||
*/
|
||||
public function disable_self_pingback(array &$links): void {
|
||||
$home = home_url();
|
||||
foreach ($links as $index => $link) {
|
||||
if (0 === strpos((string) $link, $home)) {
|
||||
unset($links[$index]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 非管理员禁止进入后台(放行 admin-ajax 与定时任务)。
|
||||
*/
|
||||
public function restrict_admin_access(): void {
|
||||
if (wp_doing_ajax() || wp_doing_cron()) {
|
||||
return;
|
||||
}
|
||||
if (!is_user_logged_in() || current_user_can('manage_options')) {
|
||||
return;
|
||||
}
|
||||
wp_safe_redirect(home_url('/'));
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* 内容防刷限制:检查并计数(10 分钟窗口 + 当天两个维度,0 = 不限制)。
|
||||
*
|
||||
* @param string $scope post|comment|upload|register|order
|
||||
* @param string $subject 用户 ID 或 IP 等限流主体
|
||||
* @param int $cap_10 10 分钟上限
|
||||
* @param int $cap_day 一天上限
|
||||
* @return true|string true = 放行(并计数),字符串 = 拒绝原因
|
||||
*/
|
||||
public static function brush_allow(string $scope, string $subject, int $cap_10, int $cap_day) {
|
||||
if ($cap_10 <= 0 && $cap_day <= 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$scope = sanitize_key($scope);
|
||||
$hash = md5((string) $subject);
|
||||
$minute_key = sprintf('aksrt_brush_%s_m_%s_%s%d', $scope, $hash, wp_date('YmdH'), intdiv((int) wp_date('i'), 10));
|
||||
$day_key = sprintf('aksrt_brush_%s_d_%s_%s', $scope, $hash, wp_date('Ymd'));
|
||||
|
||||
if ($cap_10 > 0 && (int) get_transient($minute_key) >= $cap_10) {
|
||||
return __('操作过于频繁,请稍后再试', 'aksrt');
|
||||
}
|
||||
if ($cap_day > 0 && (int) get_transient($day_key) >= $cap_day) {
|
||||
return __('今日操作次数已达上限,请明天再试', 'aksrt');
|
||||
}
|
||||
|
||||
if ($cap_10 > 0) {
|
||||
set_transient($minute_key, (int) get_transient($minute_key) + 1, 10 * MINUTE_IN_SECONDS);
|
||||
}
|
||||
if ($cap_day > 0) {
|
||||
set_transient($day_key, (int) get_transient($day_key) + 1, DAY_IN_SECONDS);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 错误频率限制:返回下一次验证需等待的秒数(0 = 可立即验证)。
|
||||
* 错误超过 5 次限 30 秒一次,超过 10 次限 3 分钟一次。
|
||||
*/
|
||||
public static function error_frequency_wait(): int {
|
||||
$settings = \AKSRT\Settings\ExtensionRuntime::get_security_settings();
|
||||
if (empty($settings['security_error_frequency_limit'])) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$state = get_transient(self::error_frequency_key());
|
||||
if (!is_array($state)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$count = (int) ($state['count'] ?? 0);
|
||||
$last = (int) ($state['last'] ?? 0);
|
||||
if ($count < 5) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$remaining = ($count >= 10 ? 180 : 30) - (time() - $last);
|
||||
return $remaining > 0 ? $remaining : 0;
|
||||
}
|
||||
|
||||
/** 记录一次验证失败(滑动 10 分钟窗口)。 */
|
||||
public static function error_frequency_record(): void {
|
||||
$settings = \AKSRT\Settings\ExtensionRuntime::get_security_settings();
|
||||
if (empty($settings['security_error_frequency_limit'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$key = self::error_frequency_key();
|
||||
$state = get_transient($key);
|
||||
$count = is_array($state) ? (int) ($state['count'] ?? 0) + 1 : 1;
|
||||
set_transient($key, ['count' => $count, 'last' => time()], 10 * MINUTE_IN_SECONDS);
|
||||
}
|
||||
|
||||
/** 验证成功后清除错误计数。 */
|
||||
public static function error_frequency_clear(): void {
|
||||
delete_transient(self::error_frequency_key());
|
||||
}
|
||||
|
||||
private static function error_frequency_key(): string {
|
||||
$ip = function_exists('aksrt_get_client_ip') ? aksrt_get_client_ip() : (string) ($_SERVER['REMOTE_ADDR'] ?? '');
|
||||
return 'aksrt_err_freq_' . md5((string) get_current_user_id() . '|' . $ip);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
/**
|
||||
* 网站安全:验证码邮件/短信发送频率限制([防刷机制]验证码发送限制)。
|
||||
*
|
||||
* 五个维度全部可配置(0 = 不限制):同一收件人冷却、同一收件人 1 小时/一天上限、
|
||||
* 同一 IP 10 分钟/一天上限。短信在 SMS::send_code 内统一接入,邮件在发送验证码
|
||||
* 的调用点接入。
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Extensions;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
class SendLimit {
|
||||
|
||||
/**
|
||||
* 发送前检查。命中任一限制返回 ['ok' => false, 'message' => ..., 'remaining' => 秒]。
|
||||
*
|
||||
* @return array{ok:bool,message:string,remaining:int}
|
||||
*/
|
||||
public static function check(string $target): array {
|
||||
$settings = \AKSRT\Settings\ExtensionRuntime::get_security_settings();
|
||||
$target_hash = md5(strtolower(trim($target)));
|
||||
$ip_hash = md5(self::client_ip());
|
||||
|
||||
if ($settings['security_captcha_send_cooldown'] > 0) {
|
||||
$sent_at = (int) get_transient('aksrt_send_cd_' . $target_hash);
|
||||
if ($sent_at > 0) {
|
||||
$remaining = $settings['security_captcha_send_cooldown'] - (time() - $sent_at);
|
||||
if ($remaining > 0) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'message' => sprintf(__('请 %d 秒后再试', 'aksrt'), $remaining),
|
||||
'remaining' => $remaining,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$recipient_checks = [
|
||||
['security_captcha_to_hour', 'aksrt_send_to_h_' . $target_hash . '_' . wp_date('YmdH'), __('该邮箱或手机号1小时内验证码发送次数已达上限', 'aksrt')],
|
||||
['security_captcha_to_day', 'aksrt_send_to_d_' . $target_hash . '_' . wp_date('Ymd'), __('该邮箱或手机号今日验证码发送次数已达上限', 'aksrt')],
|
||||
];
|
||||
$ip_checks = [
|
||||
['security_captcha_ip_10min', sprintf('aksrt_send_ip_m_%s_%s%d', $ip_hash, wp_date('YmdH'), intdiv((int) wp_date('i'), 10)), __('当前IP验证码发送过于频繁,请稍后再试', 'aksrt')],
|
||||
['security_captcha_ip_day', 'aksrt_send_ip_d_' . $ip_hash . '_' . wp_date('Ymd'), __('当前IP今日验证码发送次数已达上限', 'aksrt')],
|
||||
];
|
||||
|
||||
foreach (array_merge($recipient_checks, $ip_checks) as [$cap_key, $transient_key, $message]) {
|
||||
if ($settings[$cap_key] > 0 && (int) get_transient($transient_key) >= $settings[$cap_key]) {
|
||||
return ['ok' => false, 'message' => $message, 'remaining' => $settings['security_captcha_send_cooldown']];
|
||||
}
|
||||
}
|
||||
|
||||
return ['ok' => true, 'message' => '', 'remaining' => 0];
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送成功后记录计数,返回冷却秒数(用于前端倒计时)。
|
||||
*/
|
||||
public static function record(string $target): int {
|
||||
$settings = \AKSRT\Settings\ExtensionRuntime::get_security_settings();
|
||||
$target_hash = md5(strtolower(trim($target)));
|
||||
$ip = self::client_ip();
|
||||
$ip_hash = md5($ip);
|
||||
|
||||
if ($settings['security_captcha_to_hour'] > 0) {
|
||||
$key = 'aksrt_send_to_h_' . $target_hash . '_' . wp_date('YmdH');
|
||||
set_transient($key, (int) get_transient($key) + 1, HOUR_IN_SECONDS);
|
||||
}
|
||||
if ($settings['security_captcha_to_day'] > 0) {
|
||||
$key = 'aksrt_send_to_d_' . $target_hash . '_' . wp_date('Ymd');
|
||||
set_transient($key, (int) get_transient($key) + 1, DAY_IN_SECONDS);
|
||||
}
|
||||
if ($settings['security_captcha_ip_10min'] > 0) {
|
||||
$key = sprintf('aksrt_send_ip_m_%s_%s%d', $ip_hash, wp_date('YmdH'), intdiv((int) wp_date('i'), 10));
|
||||
set_transient($key, (int) get_transient($key) + 1, 10 * MINUTE_IN_SECONDS);
|
||||
}
|
||||
if ($settings['security_captcha_ip_day'] > 0) {
|
||||
$key = 'aksrt_send_ip_d_' . $ip_hash . '_' . wp_date('Ymd');
|
||||
set_transient($key, (int) get_transient($key) + 1, DAY_IN_SECONDS);
|
||||
}
|
||||
|
||||
$cooldown = $settings['security_captcha_send_cooldown'];
|
||||
if ($cooldown > 0) {
|
||||
set_transient('aksrt_send_cd_' . $target_hash, time(), $cooldown);
|
||||
}
|
||||
return $cooldown;
|
||||
}
|
||||
|
||||
private static function client_ip(): string {
|
||||
return function_exists('aksrt_get_client_ip') ? aksrt_get_client_ip() : (string) ($_SERVER['REMOTE_ADDR'] ?? '0.0.0.0');
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,19 @@ trait OrderRepositoryCore {
|
||||
public function create(array $data): string {
|
||||
global $wpdb;
|
||||
|
||||
// [防刷机制]下单限制(后台「扩展&增强 → 网站安全」),管理员不受限,未登录按 IP。
|
||||
if (class_exists('\AKSRT\Extensions\SecurityHardening') && !current_user_can('manage_options')) {
|
||||
$security = \AKSRT\Settings\ExtensionRuntime::get_security_settings();
|
||||
$order_user_id = (int) ($data['user_id'] ?? 0);
|
||||
$brush_subject = $order_user_id > 0
|
||||
? (string) $order_user_id
|
||||
: (function_exists('aksrt_get_client_ip') ? aksrt_get_client_ip() : (string) ($_SERVER['REMOTE_ADDR'] ?? ''));
|
||||
$brush = \AKSRT\Extensions\SecurityHardening::brush_allow('order', $brush_subject, (int) $security['security_brush_order_10min'], (int) $security['security_brush_order_day']);
|
||||
if ($brush !== true) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// 自带订单号先过形态校验:只接受分配器能产出的「全数字、20 或 22 位」。
|
||||
// 不合规的一律弃用并回落到分配器(WP_DEBUG 下留痕),既不把非法长度写进库,
|
||||
// 也不因此抛错中断调用方。
|
||||
|
||||
@@ -154,6 +154,41 @@ class ExtensionRuntime {
|
||||
];
|
||||
}
|
||||
|
||||
/** 获取网站安全设置(防刷限制、验证码发送限制与安全加固)。 */
|
||||
public static function get_security_settings(): array {
|
||||
$settings = Settings::get_all();
|
||||
|
||||
$number = static function (string $key, int $default, int $min, int $max) use ($settings): int {
|
||||
return max($min, min($max, (int) ($settings[$key] ?? $default)));
|
||||
};
|
||||
$switch = static function (string $key, bool $default) use ($settings): bool {
|
||||
return !isset($settings[$key]) ? $default : !empty($settings[$key]);
|
||||
};
|
||||
|
||||
return [
|
||||
'security_brush_post_10min' => $number('security_brush_post_10min', 20, 0, 200),
|
||||
'security_brush_post_day' => $number('security_brush_post_day', 50, 0, 2000),
|
||||
'security_brush_comment_10min' => $number('security_brush_comment_10min', 20, 0, 200),
|
||||
'security_brush_comment_day' => $number('security_brush_comment_day', 100, 0, 2000),
|
||||
'security_brush_upload_10min' => $number('security_brush_upload_10min', 50, 0, 200),
|
||||
'security_brush_upload_day' => $number('security_brush_upload_day', 200, 0, 2000),
|
||||
'security_brush_register_10min' => $number('security_brush_register_10min', 20, 0, 200),
|
||||
'security_brush_register_day' => $number('security_brush_register_day', 100, 0, 2000),
|
||||
'security_brush_order_10min' => $number('security_brush_order_10min', 30, 0, 200),
|
||||
'security_brush_order_day' => $number('security_brush_order_day', 100, 0, 2000),
|
||||
'security_captcha_send_cooldown' => $number('security_captcha_send_cooldown', 60, 0, 600),
|
||||
'security_captcha_to_hour' => $number('security_captcha_to_hour', 15, 0, 500),
|
||||
'security_captcha_to_day' => $number('security_captcha_to_day', 30, 0, 1000),
|
||||
'security_captcha_ip_10min' => $number('security_captcha_ip_10min', 20, 0, 500),
|
||||
'security_captcha_ip_day' => $number('security_captcha_ip_day', 50, 0, 2000),
|
||||
'security_error_frequency_limit' => $switch('security_error_frequency_limit', true),
|
||||
'security_disabled_pingback' => $switch('security_disabled_pingback', true),
|
||||
'security_disabled_xmlrpc' => $switch('security_disabled_xmlrpc', true),
|
||||
'security_user_disable_admin' => $switch('security_user_disable_admin', true),
|
||||
'close_attachment_page' => $switch('close_attachment_page', true),
|
||||
];
|
||||
}
|
||||
|
||||
/** 获取短信接口设置。 */
|
||||
public static function get_sms_settings(): array {
|
||||
$settings = Settings::get_all();
|
||||
|
||||
+7
-22
@@ -14,9 +14,6 @@ class SMS {
|
||||
|
||||
private static $instance = null;
|
||||
|
||||
private const RATE_LIMIT = 60;
|
||||
private const DAILY_LIMIT = 10;
|
||||
|
||||
public static function get_instance(): self {
|
||||
if (null === self::$instance) {
|
||||
self::$instance = new self();
|
||||
@@ -72,39 +69,27 @@ class SMS {
|
||||
return ['success' => false, 'message' => sprintf(__('短信接口缺少配置:%s', 'aksrt'), implode(', ', $missing))];
|
||||
}
|
||||
|
||||
$phone_hash = md5($phone);
|
||||
$rate_limit = self::RATE_LIMIT;
|
||||
$rate_key = 'aksrt_sms_rate_' . $phone_hash;
|
||||
if (get_transient($rate_key) !== false) {
|
||||
$sent_time = (int) get_transient('aksrt_sms_rate_ts_' . $phone_hash);
|
||||
$remaining = max(1, $rate_limit - (time() - $sent_time));
|
||||
// [防刷机制]验证码发送限制(后台「扩展&增强 → 网站安全」)。
|
||||
$limit = \AKSRT\Extensions\SendLimit::check($phone);
|
||||
if (empty($limit['ok'])) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => sprintf(__('请 %d 秒后再试', 'aksrt'), $remaining),
|
||||
'remaining_time' => $remaining,
|
||||
'message' => (string) $limit['message'],
|
||||
'remaining_time' => (int) $limit['remaining'],
|
||||
];
|
||||
}
|
||||
|
||||
$daily_limit = self::DAILY_LIMIT;
|
||||
$daily_key = 'aksrt_sms_daily_' . $phone_hash . '_' . wp_date('Ymd');
|
||||
$daily_count = (int) get_transient($daily_key);
|
||||
if ($daily_count >= $daily_limit) {
|
||||
return ['success' => false, 'message' => __('今日发送次数已达上限', 'aksrt')];
|
||||
}
|
||||
|
||||
$result = $provider->send_code($phone, $code, $config);
|
||||
if (empty($result['success'])) {
|
||||
return ['success' => false, 'message' => (string) ($result['message'] ?? __('验证码发送失败,请稍后重试', 'aksrt'))];
|
||||
}
|
||||
|
||||
set_transient($rate_key, '1', $rate_limit);
|
||||
set_transient('aksrt_sms_rate_ts_' . $phone_hash, time(), $rate_limit);
|
||||
set_transient($daily_key, $daily_count + 1, DAY_IN_SECONDS);
|
||||
$cooldown = \AKSRT\Extensions\SendLimit::record($phone);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => __('验证码短信已发送', 'aksrt'),
|
||||
'remaining_time' => $rate_limit,
|
||||
'remaining_time' => $cooldown > 0 ? $cooldown : 60,
|
||||
];
|
||||
}
|
||||
public function get_provider_status(): array {
|
||||
|
||||
Reference in New Issue
Block a user