diff --git a/plugins/itstudio-recruitment/includes/email-otp.php b/plugins/itstudio-recruitment/includes/email-otp.php new file mode 100644 index 0000000..643e1d4 --- /dev/null +++ b/plugins/itstudio-recruitment/includes/email-otp.php @@ -0,0 +1,253 @@ + !empty($settings['otp_enabled']), + 'form_id' => $form_id, + 'email_field_id' => absint($settings['otp_email_field_id'] ?? 0), + 'code_field_id' => absint($settings['otp_code_field_id'] ?? 0), + 'ttl' => 10 * MINUTE_IN_SECONDS, + 'cooldown' => 60, + 'max_attempts' => 5, + ); +} + +function itstudio_join_otp_is_configured() { + $config = itstudio_join_otp_get_config(); + return !empty($config['enabled']) + && $config['form_id'] > 0 + && $config['email_field_id'] > 0 + && $config['code_field_id'] > 0; +} + +function itstudio_join_otp_normalize_email($email) { + return strtolower(trim(sanitize_email((string) $email))); +} + +function itstudio_join_otp_get_client_ip() { + return isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash((string) $_SERVER['REMOTE_ADDR'])) : ''; +} + +function itstudio_join_otp_key($email, $form_id) { + return 'itstudio_join_otp_' . md5((string) $form_id . '|' . itstudio_join_otp_normalize_email($email)); +} + +function itstudio_join_otp_ip_key($ip) { + return 'itstudio_join_otp_ip_' . md5((string) $ip); +} + +function itstudio_join_otp_hash_code($email, $code) { + return hash_hmac('sha256', itstudio_join_otp_normalize_email($email) . '|' . (string) $code, wp_salt('auth')); +} + +function itstudio_join_otp_random_code() { + return (string) wp_rand(100000, 999999); +} + +function itstudio_join_otp_json_error($message_cn, $message_en, $status = 400, $extra = array()) { + wp_send_json_error( + array_merge( + array( + 'message_cn' => $message_cn, + 'message_en' => $message_en, + ), + $extra + ), + $status + ); +} + +function itstudio_join_otp_send_ajax() { + if (!check_ajax_referer('itstudio_join_otp', 'nonce', false)) { + itstudio_join_otp_json_error('请求已过期,请刷新页面后重试。', 'Request expired. Please refresh the page and try again.', 403); + } + + $config = itstudio_join_otp_get_config(); + if (!itstudio_join_otp_is_configured()) { + itstudio_join_otp_json_error('邮箱验证码尚未完成配置,请联系管理员。', 'Email verification is not configured yet. Please contact the administrator.', 503); + } + + $email = itstudio_join_otp_normalize_email(isset($_POST['email']) ? wp_unslash($_POST['email']) : ''); + if ($email === '' || !is_email($email)) { + itstudio_join_otp_json_error('请输入有效的邮箱地址。', 'Please enter a valid email address.', 400); + } + + $ip = itstudio_join_otp_get_client_ip(); + $otp_key = itstudio_join_otp_key($email, $config['form_id']); + $existing = get_transient($otp_key); + $now = time(); + + if (is_array($existing) && !empty($existing['last_sent']) && ($now - (int) $existing['last_sent']) < $config['cooldown']) { + $wait = max(1, $config['cooldown'] - ($now - (int) $existing['last_sent'])); + itstudio_join_otp_json_error("请等待 {$wait} 秒后再发送。", "Please wait {$wait} seconds before requesting another code.", 429, array('wait' => $wait)); + } + + if ($ip !== '') { + $ip_key = itstudio_join_otp_ip_key($ip); + $ip_last_sent = (int) get_transient($ip_key); + if ($ip_last_sent > 0 && ($now - $ip_last_sent) < 10) { + $wait = max(1, 10 - ($now - $ip_last_sent)); + itstudio_join_otp_json_error("请求过于频繁,请等待 {$wait} 秒。", "Too many requests. Please wait {$wait} seconds.", 429, array('wait' => $wait)); + } + set_transient($ip_key, $now, 10); + } + + $code = itstudio_join_otp_random_code(); + $payload = array( + 'code_hash' => itstudio_join_otp_hash_code($email, $code), + 'expires' => $now + $config['ttl'], + 'last_sent' => $now, + 'attempts' => 0, + 'form_id' => $config['form_id'], + 'email' => $email, + ); + set_transient($otp_key, $payload, $config['ttl']); + + $site_name = wp_specialchars_decode(get_bloginfo('name'), ENT_QUOTES); + $subject = sprintf('%s 报名邮箱验证码', $site_name !== '' ? $site_name : '爱特工作室'); + $message = "您好!\n\n您的报名邮箱验证码是:{$code}\n\n验证码 10 分钟内有效,且只能使用一次。如非本人操作,请忽略此邮件。\n\n{$site_name}"; + $sent = wp_mail($email, $subject, $message, array('Content-Type: text/plain; charset=UTF-8')); + + if (!$sent) { + delete_transient($otp_key); + itstudio_join_otp_json_error('邮件发送失败,请稍后重试。', 'The email could not be sent. Please try again later.', 500); + } + + wp_send_json_success( + array( + 'message_cn' => '验证码已发送,请检查邮箱及垃圾邮件。', + 'message_en' => 'Verification code sent. Please check your inbox.', + 'cooldown' => $config['cooldown'], + 'ttl' => $config['ttl'], + ) + ); +} +add_action('wp_ajax_itstudio_join_otp_send', 'itstudio_join_otp_send_ajax'); +add_action('wp_ajax_nopriv_itstudio_join_otp_send', 'itstudio_join_otp_send_ajax'); + +function itstudio_join_otp_validate_field($errors, $posted_field, $posted_value) { + if (!is_object($posted_field)) { + return $errors; + } + + $config = itstudio_join_otp_get_config(); + if (!itstudio_join_otp_is_configured() + || (int) ($posted_field->form_id ?? 0) !== $config['form_id'] + || (int) ($posted_field->id ?? 0) !== $config['code_field_id']) { + return $errors; + } + + $email = itstudio_join_otp_normalize_email(isset($_POST['item_meta'][$config['email_field_id']]) ? wp_unslash($_POST['item_meta'][$config['email_field_id']]) : ''); + $code = is_scalar($posted_value) ? trim((string) $posted_value) : ''; + $field_key = 'field' . $config['code_field_id']; + + if ($email === '' || !is_email($email)) { + $errors[$field_key] = '请输入有效的邮箱并先获取验证码。'; + return $errors; + } + if (!preg_match('/^\d{6}$/', $code)) { + $errors[$field_key] = '请输入 6 位数字验证码。'; + return $errors; + } + + $otp_key = itstudio_join_otp_key($email, $config['form_id']); + $payload = get_transient($otp_key); + if (!is_array($payload) || empty($payload['code_hash']) || empty($payload['expires'])) { + $errors[$field_key] = '验证码不存在或已失效,请重新获取。'; + return $errors; + } + if (time() > (int) $payload['expires']) { + delete_transient($otp_key); + $errors[$field_key] = '验证码已过期,请重新获取。'; + return $errors; + } + if ((int) ($payload['attempts'] ?? 0) >= $config['max_attempts']) { + delete_transient($otp_key); + $errors[$field_key] = '验证码尝试次数过多,请重新获取。'; + return $errors; + } + if (!hash_equals((string) $payload['code_hash'], itstudio_join_otp_hash_code($email, $code))) { + $payload['attempts'] = (int) ($payload['attempts'] ?? 0) + 1; + $remaining = max(0, (int) $payload['expires'] - time()); + if ($remaining > 0) { + set_transient($otp_key, $payload, $remaining); + } + $errors[$field_key] = '验证码错误,请重试。'; + return $errors; + } + + $payload['verified_at'] = time(); + $remaining = max(1, (int) $payload['expires'] - time()); + set_transient($otp_key, $payload, $remaining); + return $errors; +} +add_filter('frm_validate_field_entry', 'itstudio_join_otp_validate_field', 20, 3); + +function itstudio_join_otp_consume_after_entry($entry_id, $form_id) { + $config = itstudio_join_otp_get_config(); + if (!itstudio_join_otp_is_configured() || (int) $form_id !== $config['form_id']) { + return; + } + + $email = itstudio_join_otp_normalize_email(isset($_POST['item_meta'][$config['email_field_id']]) ? wp_unslash($_POST['item_meta'][$config['email_field_id']]) : ''); + if ($email !== '' && is_email($email)) { + delete_transient(itstudio_join_otp_key($email, $config['form_id'])); + } +} +add_action('frm_after_create_entry', 'itstudio_join_otp_consume_after_entry', 20, 2); + +function itstudio_join_otp_enqueue_assets() { + if (!itstudio_is_join_page_context() || !itstudio_join_otp_is_configured() || !shortcode_exists('formidable')) { + return; + } + + $config = itstudio_join_otp_get_config(); + + wp_enqueue_style( + 'itstudio-join-otp', + plugins_url('../assets/otp.css', __FILE__), + array('itstudio-join-page'), + ITSTUDIO_RECRUITMENT_VERSION + ); + wp_enqueue_script( + 'itstudio-join-otp', + plugins_url('../assets/otp.js', __FILE__), + array(), + ITSTUDIO_RECRUITMENT_VERSION, + true + ); + wp_localize_script( + 'itstudio-join-otp', + 'itstudioJoinOtp', + array( + 'formId' => $config['form_id'], + 'emailFieldId' => $config['email_field_id'], + 'codeFieldId' => $config['code_field_id'], + 'ajaxUrl' => admin_url('admin-ajax.php'), + 'nonce' => wp_create_nonce('itstudio_join_otp'), + 'cooldown' => $config['cooldown'], + 'labels' => array( + 'send' => '发送验证码', + 'resend' => '重新发送 (%s秒)', + 'sending' => '发送中…', + 'emailRequired' => '请先填写邮箱地址。', + 'invalidEmail' => '请输入有效的邮箱地址。', + 'networkError' => '网络错误,请稍后重试。', + ), + ) + ); +} +add_action('wp_enqueue_scripts', 'itstudio_join_otp_enqueue_assets', 40); diff --git a/plugins/itstudio-recruitment/includes/join-system.php b/plugins/itstudio-recruitment/includes/join-system.php index 23161a2..469787d 100644 --- a/plugins/itstudio-recruitment/includes/join-system.php +++ b/plugins/itstudio-recruitment/includes/join-system.php @@ -63,6 +63,10 @@ function itstudio_join_get_default_settings() { 'photo_public_notice' => 0, 'photo_inactive' => 0, 'signup_form_shortcode' => '', + 'otp_enabled' => 0, + 'otp_form_id' => '', + 'otp_email_field_id' => '', + 'otp_code_field_id' => '', ); } @@ -179,6 +183,10 @@ function itstudio_join_sanitize_settings($input) { 'result_second_interview_records' => itstudio_join_sanitize_records_value($input['result_second_interview_records'] ?? ''), 'result_admission_records' => itstudio_join_sanitize_records_value($input['result_admission_records'] ?? ''), 'signup_form_shortcode' => itstudio_join_sanitize_shortcode_value($input['signup_form_shortcode'] ?? ''), + 'otp_enabled' => !empty($input['otp_enabled']) ? 1 : 0, + 'otp_form_id' => absint($input['otp_form_id'] ?? 0), + 'otp_email_field_id' => absint($input['otp_email_field_id'] ?? 0), + 'otp_code_field_id' => absint($input['otp_code_field_id'] ?? 0), ); foreach (itstudio_join_get_photo_field_map() as $field_key) { @@ -2385,6 +2393,23 @@ function itstudio_join_render_settings_page() {

示例:[formidable id="12"]

+ + 邮箱 OTP 验证 + + +

启用后,前台会在指定验证码字段旁自动显示“发送验证码”按钮,不需要手动添加 HTML 按钮。

+

+ +

+ +

+ +

验证码字段建议使用 Short Text,设置为仅允许数字、最大长度 6。邮箱字段必须是 Email 类型。验证码有效期 10 分钟,同一邮箱 60 秒内不能重复发送,最多尝试 5 次。

+ + diff --git a/plugins/itstudio-recruitment/itstudio-recruitment.php b/plugins/itstudio-recruitment/itstudio-recruitment.php index 86f8c77..cf12e7d 100644 --- a/plugins/itstudio-recruitment/itstudio-recruitment.php +++ b/plugins/itstudio-recruitment/itstudio-recruitment.php @@ -23,7 +23,9 @@ define('ITSTUDIO_RECRUITMENT_PATH', plugin_dir_path(__FILE__)); require_once ITSTUDIO_RECRUITMENT_PATH . 'includes/recruitment-articles.php'; require_once ITSTUDIO_RECRUITMENT_PATH . 'includes/join-system.php'; +require_once ITSTUDIO_RECRUITMENT_PATH . 'includes/email-otp.php'; require_once ITSTUDIO_RECRUITMENT_PATH . 'includes/terms.php'; +require_once ITSTUDIO_RECRUITMENT_PATH . 'includes/formidable-async-patch.php'; function itstudio_recruitment_activate() { flush_rewrite_rules(); diff --git a/themes/ITStudioMainSite/functions.php b/themes/ITStudioMainSite/functions.php index 3292b11..045d122 100644 --- a/themes/ITStudioMainSite/functions.php +++ b/themes/ITStudioMainSite/functions.php @@ -777,3 +777,7 @@ function itstudio_join_fallback() { exit; } add_action('template_redirect', 'itstudio_join_fallback', 9); +if (file_exists(get_template_directory() . '/includes/formidable-async-patch.php')) { + require_once get_template_directory() . '/includes/formidable-async-patch.php'; +} +