feat(security): 新增网站安全扩展模块并支持修改手机脱敏验证
This commit is contained in:
1 parent
224f86ec08
commit
af72364078
30 files changed
+1389
-73
No files matched your search
@@ -19,6 +19,7 @@ require_once AKSRT_DIR . '/inc/helpers/aksrt-avatar.php';
|
||||
require_once AKSRT_DIR . '/inc/helpers/aksrt-domain-model.php';
|
||||
require_once AKSRT_DIR . '/inc/helpers/aksrt-template-tags.php';
|
||||
require_once AKSRT_DIR . '/inc/helpers/aksrt-frontend-tags.php';
|
||||
require_once AKSRT_DIR . '/inc/helpers/aksrt-phone.php';
|
||||
// Shared comment kernel.
|
||||
require_once AKSRT_DIR . '/inc/helpers/aksrt-ip-geo.php';
|
||||
require_once AKSRT_DIR . '/inc/helpers/comments/content.php';
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
/**
|
||||
* Phone number masking helpers shared by the account UI and the
|
||||
* phone-match verification endpoint.
|
||||
*
|
||||
* The hidden middle segment adapts to the number: domestic 11-digit
|
||||
* numbers hide 4 digits (138****1234), longer international numbers
|
||||
* hide length-7 digits (min 4), so the digit-entry UI renders one box
|
||||
* per hidden digit.
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
if (!defined('ABSPATH')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a bound phone number into visible prefix / hidden middle / visible suffix.
|
||||
*
|
||||
* Works on the digit core of the number (a leading "+" is ignored for the split
|
||||
* but reported back). Returns null when the number holds fewer than 8 digits —
|
||||
* such numbers cannot be verified by middle-digit entry.
|
||||
*
|
||||
* @return array{prefix:string,hidden:string,suffix:string,hidden_length:int,leading_plus:bool}|null
|
||||
*/
|
||||
function aksrt_phone_mask_parts(string $phone): ?array {
|
||||
$phone = trim($phone);
|
||||
if ($phone === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$leading_plus = $phone[0] === '+';
|
||||
$core = preg_replace('/\D+/', '', $phone);
|
||||
$length = strlen($core);
|
||||
if ($length < 8) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$hidden_length = min(11, max(4, $length - 7));
|
||||
$suffix_length = $length - 3 - $hidden_length;
|
||||
if ($suffix_length < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'prefix' => substr($core, 0, 3),
|
||||
'hidden' => substr($core, 3, $hidden_length),
|
||||
'suffix' => substr($core, $length - $suffix_length),
|
||||
'hidden_length' => $hidden_length,
|
||||
'leading_plus' => $leading_plus,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Masked display form of a phone number, e.g. 138****1234 or +861******1234.
|
||||
* Falls back to the raw number when it cannot be masked.
|
||||
*/
|
||||
function aksrt_phone_mask_display(string $phone): string {
|
||||
$parts = aksrt_phone_mask_parts($phone);
|
||||
if ($parts === null) {
|
||||
return $phone;
|
||||
}
|
||||
|
||||
$mask = str_repeat('*', (int) $parts['hidden_length']);
|
||||
$display = $parts['prefix'] . $mask . $parts['suffix'];
|
||||
return $parts['leading_plus'] ? '+' . $display : $display;
|
||||
}
|
||||
@@ -11,7 +11,7 @@ $account_user = isset($target_user) && $target_user instanceof WP_User ? $target
|
||||
$account_user_id = $account_user instanceof WP_User ? (int) $account_user->ID : (int) ($user_id ?? 0);
|
||||
$account_email = $account_user instanceof WP_User ? (string) $account_user->user_email : '';
|
||||
$account_phone = $account_user_id > 0 ? (string) get_user_meta($account_user_id, 'phone_number', true) : '';
|
||||
$account_phone_display = strlen($account_phone) > 10 ? substr_replace($account_phone, '****', 3, 4) : $account_phone;
|
||||
$account_phone_display = function_exists('aksrt_phone_mask_display') ? aksrt_phone_mask_display($account_phone) : $account_phone;
|
||||
$account_oauth_new = $account_user_id > 0 && get_user_meta($account_user_id, 'oauth_new', true);
|
||||
$user_settings = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_all() : [];
|
||||
$sms_settings = class_exists('\AKSRT\Sms\SMS') ? \AKSRT\Sms\SMS::get_instance()->get_provider_config() : [];
|
||||
@@ -147,7 +147,29 @@ $render_phone_form = static function () use ($agreement): void {
|
||||
<?php if ($account_phone !== '') : ?>
|
||||
<ul class="mb20 step-simple account-step-list" data-account-step-list="phone"><li class="active"><span><?php esc_html_e('验证老手机', 'aksrt'); ?></span></li><li><span><?php esc_html_e('验证新手机', 'aksrt'); ?></span></li><li><span><?php esc_html_e('修改成功', 'aksrt'); ?></span></li></ul>
|
||||
<div class="account-step-panels">
|
||||
<div class="account-step is-active" data-account-step="phone-old"><p class="muted-2-color"><?php printf(esc_html__('请在下方获取验证码以验证您的老手机号 %s', 'aksrt'), '<br><span class="badg">' . esc_html($account_phone_display) . '</span>'); ?></p><form id="phone-old-verify-form"><input type="hidden" name="phone" value="<?php echo esc_attr($account_phone); ?>"><div class="mb20"><?php if (function_exists('aksrt_render_captcha')) : ?><?php aksrt_render_captcha('bind_phone'); ?><?php endif; ?><div class="relative line-form mb10 modal-line-field modal-line-field--code"><input type="text" name="code" class="line-form-input" maxlength="6" inputmode="numeric" autocomplete="one-time-code" required><div class="scale-placeholder"><?php esc_html_e('验证码', 'aksrt'); ?></div><span class="yztx abs-right"><button type="button" class="but c-blue code-send-btn account-code-send" data-aksrt-sms-trigger data-aksrt-sms-scene="verify_old_phone"><?php esc_html_e('发送验证码', 'aksrt'); ?></button></span><i class="line-form-line"></i></div></div><button type="submit" class="but jb-blue padding-lg btn-block"><i class="fa fas fa-shield-alt" aria-hidden="true"></i> <?php esc_html_e('立即验证', 'aksrt'); ?></button></form></div>
|
||||
<div class="account-step is-active" data-account-step="phone-old">
|
||||
<?php $phone_mask = function_exists('aksrt_phone_mask_parts') ? aksrt_phone_mask_parts($account_phone) : null; ?>
|
||||
<?php if ($phone_mask !== null) : ?>
|
||||
<p class="muted-2-color"><?php printf(esc_html__('为确认是您本人操作,请补全绑定手机号 %s 中缺失的数字', 'aksrt'), '<br><span class="badg">' . esc_html($account_phone_display) . '</span>'); ?></p>
|
||||
<?php else : ?>
|
||||
<p class="muted-2-color"><?php printf(esc_html__('请在下方获取验证码以验证您的老手机号 %s', 'aksrt'), '<br><span class="badg">' . esc_html($account_phone_display) . '</span>'); ?></p>
|
||||
<?php endif; ?>
|
||||
<form id="phone-old-verify-form" novalidate>
|
||||
<?php if ($phone_mask !== null) : ?>
|
||||
<div class="mb20 aksrt-phone-digits" data-aksrt-phone-digits data-hidden-length="<?php echo esc_attr((string) $phone_mask['hidden_length']); ?>" role="group" aria-label="<?php esc_attr_e('请补全手机号中缺失的数字', 'aksrt'); ?>">
|
||||
<?php for ($phone_digit_index = 0; $phone_digit_index < (int) $phone_mask['hidden_length']; $phone_digit_index++) : ?>
|
||||
<input type="text" class="aksrt-phone-digit" inputmode="numeric" maxlength="1" autocomplete="one-time-code" data-digit-index="<?php echo esc_attr((string) $phone_digit_index); ?>" aria-label="<?php echo esc_attr(sprintf(__('手机号第 %d 位数字', 'aksrt'), 4 + $phone_digit_index)); ?>">
|
||||
<?php endfor; ?>
|
||||
</div>
|
||||
<p class="aksrt-phone-digits__error" data-aksrt-phone-digits-error hidden></p>
|
||||
<?php endif; ?>
|
||||
<div class="mb20" data-aksrt-phone-code-area<?php echo $phone_mask !== null ? ' hidden' : ''; ?>>
|
||||
<?php if (function_exists('aksrt_render_captcha')) : ?><?php aksrt_render_captcha('bind_phone'); ?><?php endif; ?>
|
||||
<div class="relative line-form mb10 modal-line-field modal-line-field--code"><input type="text" name="code" class="line-form-input" maxlength="6" inputmode="numeric" autocomplete="one-time-code" required><div class="scale-placeholder"><?php esc_html_e('验证码', 'aksrt'); ?></div><span class="yztx abs-right"><button type="button" class="but c-blue code-send-btn account-code-send" data-aksrt-sms-trigger data-aksrt-sms-scene="verify_old_phone"><?php esc_html_e('发送验证码', 'aksrt'); ?></button></span><i class="line-form-line"></i></div>
|
||||
<button type="submit" class="but jb-blue padding-lg btn-block"><i class="fa fas fa-shield-alt" aria-hidden="true"></i> <?php esc_html_e('立即验证', 'aksrt'); ?></button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="account-step" data-account-step="phone-new"><p class="muted-2-color"><?php esc_html_e('请输入您需要修改的新手机号码', 'aksrt'); ?></p><?php $render_phone_form(); ?></div>
|
||||
<div class="account-step" data-account-step="phone-success"><div class="text-center padding-h10"><i class="fa fas fa-check-circle c-green em3x mb10" aria-hidden="true"></i><p><?php esc_html_e('手机号修改成功', 'aksrt'); ?></p></div></div>
|
||||
</div>
|
||||
|
||||
@@ -92,21 +92,6 @@ function aksrt_account_consume_proof(int $user_id, string $kind, string $target,
|
||||
return true;
|
||||
}
|
||||
|
||||
function aksrt_account_rate_limited(int $user_id, string $scene): bool {
|
||||
$key = 'aksrt_account_rate_' . sanitize_key($scene) . '_' . $user_id;
|
||||
$count = (int) get_transient($key);
|
||||
if ($count >= 8) {
|
||||
return true;
|
||||
}
|
||||
|
||||
set_transient($key, $count + 1, 10 * MINUTE_IN_SECONDS);
|
||||
return false;
|
||||
}
|
||||
|
||||
function aksrt_account_reset_rate(int $user_id, string $scene): void {
|
||||
delete_transient('aksrt_account_rate_' . sanitize_key($scene) . '_' . $user_id);
|
||||
}
|
||||
|
||||
function aksrt_account_email_verification_required(): bool {
|
||||
$settings = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_all() : [];
|
||||
return !empty($settings['email_set_captch']);
|
||||
@@ -155,11 +140,12 @@ add_action('wp_ajax_aksrt_send_account_email_code', function (): void {
|
||||
wp_send_json_error(aksrt_account_captcha_error_data($captcha));
|
||||
}
|
||||
}
|
||||
$rate_key = 'aksrt_account_email_rate_' . $user_id . '_' . $scene . '_' . md5(strtolower($target));
|
||||
if (get_transient($rate_key)) {
|
||||
wp_send_json_error(['message' => __('操作过于频繁,请稍后再试', 'aksrt')]);
|
||||
if (class_exists('\AKSRT\Extensions\SendLimit')) {
|
||||
$limit = \AKSRT\Extensions\SendLimit::check($target);
|
||||
if (empty($limit['ok'])) {
|
||||
wp_send_json_error(['message' => (string) $limit['message']]);
|
||||
}
|
||||
}
|
||||
set_transient($rate_key, true, 60);
|
||||
|
||||
$code = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);
|
||||
$sent = class_exists('\AKSRT\Settings\Settings')
|
||||
@@ -170,16 +156,16 @@ add_action('wp_ajax_aksrt_send_account_email_code', function (): void {
|
||||
])
|
||||
: false;
|
||||
if (!$sent) {
|
||||
delete_transient($rate_key);
|
||||
wp_send_json_error(['message' => __('验证码邮件发送失败,请稍后重试', 'aksrt')]);
|
||||
}
|
||||
|
||||
$cooldown = class_exists('\AKSRT\Extensions\SendLimit') ? (int) \AKSRT\Extensions\SendLimit::record($target) : 0;
|
||||
aksrt_account_store_code($user_id, $scene, $target, $code);
|
||||
wp_send_json_success([
|
||||
'message' => $scene === 'verify_email'
|
||||
? __('验证码已发送到您的老邮箱', 'aksrt')
|
||||
: __('验证码已发送到新邮箱', 'aksrt'),
|
||||
'countdown' => 60,
|
||||
'countdown' => $cooldown > 0 ? $cooldown : 60,
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -208,9 +194,15 @@ add_action('wp_ajax_aksrt_verify_account_email', function (): void {
|
||||
|
||||
$code = sanitize_text_field((string) wp_unslash($_POST['code'] ?? ''));
|
||||
if (!aksrt_account_consume_code($user_id, 'verify_email', (string) $user->user_email, $code)) {
|
||||
if (class_exists('\AKSRT\Extensions\SecurityHardening')) {
|
||||
\AKSRT\Extensions\SecurityHardening::error_frequency_record();
|
||||
}
|
||||
wp_send_json_error(['message' => __('验证码错误或已过期', 'aksrt')]);
|
||||
}
|
||||
|
||||
if (class_exists('\AKSRT\Extensions\SecurityHardening')) {
|
||||
\AKSRT\Extensions\SecurityHardening::error_frequency_clear();
|
||||
}
|
||||
aksrt_account_set_proof($user_id, 'email_old', (string) $user->user_email);
|
||||
wp_send_json_success(['message' => __('验证成功', 'aksrt')]);
|
||||
});
|
||||
@@ -250,10 +242,13 @@ add_action('wp_ajax_aksrt_change_password', function (): void {
|
||||
if ($old_password === $password) {
|
||||
wp_send_json_error(['message' => __('新密码和原密码不能相同', 'aksrt')]);
|
||||
}
|
||||
if (aksrt_account_rate_limited($user_id, 'change_password')) {
|
||||
wp_send_json_error(['message' => __('错误次数过多,请稍后再试', 'aksrt')]);
|
||||
if (class_exists('\AKSRT\Extensions\SecurityHardening') && ($wait = \AKSRT\Extensions\SecurityHardening::error_frequency_wait()) > 0) {
|
||||
wp_send_json_error(['message' => sprintf(__('验证错误次数过多,请 %d 秒后再试', 'aksrt'), $wait)]);
|
||||
}
|
||||
if (!wp_check_password($old_password, $user->user_pass, $user_id)) {
|
||||
if (class_exists('\AKSRT\Extensions\SecurityHardening')) {
|
||||
\AKSRT\Extensions\SecurityHardening::error_frequency_record();
|
||||
}
|
||||
wp_send_json_error(['message' => __('原密码错误', 'aksrt')]);
|
||||
}
|
||||
}
|
||||
@@ -271,7 +266,9 @@ add_action('wp_ajax_aksrt_change_password', function (): void {
|
||||
}
|
||||
|
||||
delete_user_meta($user_id, 'oauth_new');
|
||||
aksrt_account_reset_rate($user_id, 'change_password');
|
||||
if (class_exists('\AKSRT\Extensions\SecurityHardening')) {
|
||||
\AKSRT\Extensions\SecurityHardening::error_frequency_clear();
|
||||
}
|
||||
wp_send_json_success([
|
||||
'message' => $oauth_new
|
||||
? __('密码设置成功', 'aksrt')
|
||||
@@ -335,6 +332,9 @@ add_action('wp_ajax_aksrt_change_email', function (): void {
|
||||
false
|
||||
)
|
||||
) {
|
||||
if (class_exists('\AKSRT\Extensions\SecurityHardening')) {
|
||||
\AKSRT\Extensions\SecurityHardening::error_frequency_record();
|
||||
}
|
||||
wp_send_json_error(['message' => __('验证码错误或已过期', 'aksrt')]);
|
||||
}
|
||||
|
||||
@@ -349,6 +349,9 @@ add_action('wp_ajax_aksrt_change_email', function (): void {
|
||||
if ($verification_required) {
|
||||
delete_transient(aksrt_account_code_key($user_id, 'change_email', $new_email));
|
||||
}
|
||||
if (class_exists('\AKSRT\Extensions\SecurityHardening')) {
|
||||
\AKSRT\Extensions\SecurityHardening::error_frequency_clear();
|
||||
}
|
||||
update_user_meta($user_id, 'aksrt_email_verified', 1);
|
||||
if ($old_email !== '' && is_email($old_email) && class_exists('\AKSRT\Settings\Settings')) {
|
||||
\AKSRT\Settings\Settings::send_event_email('change_notice', $old_email, [
|
||||
|
||||
Reference in new issue
Block a user