feat(security): 新增网站安全扩展模块并支持修改手机脱敏验证

This commit is contained in:
kate522 committed 2026-10-06 09:26:55 +08:00
1 parent 224f86ec08
commit af72364078
30 files changed
+1389 -73

No files matched your search

+1
View File
@@ -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,能有效提高文件的安全性'],
]],
],
];
}
}
-1
View File
@@ -137,7 +137,6 @@ class SchemaPermissions {
['label' => '时间 + 原文件名', 'value' => 'time'],
['label' => '随机字符串', 'value' => 'random'],
]],
['key' => 'close_attachment_page', 'label' => '非管理员屏蔽附件页面', 'type' => 'switch', 'default' => true, 'help' => '开启后非管理员访问附件(图片、文件等)页面将直接显示 404,可有效提高文件的安全性。'],
]],
],
],
+5
View File
@@ -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'],
+105 -10
View File
@@ -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')]);