['GET', 'POST'], 'callback' => [$this, 'rest_qrcode_login'], 'permission_callback' => '__return_true', ]); register_rest_route('jio/v1', '/auth/check-qrcode-login', [ 'methods' => ['GET', 'POST'], 'callback' => [$this, 'rest_check_qrcode_login'], 'permission_callback' => '__return_true', ]); register_rest_route('jio/v1', '/auth/weixingzh-code', [ 'methods' => 'POST', 'callback' => [$this, 'rest_code_login'], 'permission_callback' => '__return_true', ]); } /** * @return array */ public static function settings(): array { return class_exists(Settings::class) ? (array) Settings::get_weixingzh_settings() : []; } public static function is_enabled(): bool { if (!class_exists(Settings::class) || !OAuthProviderCatalog::master_enabled()) { return false; } $all = Settings::get_all(); if (empty($all['oauth_weixingzh_s'])) { return false; } $config = self::settings(); $type = sanitize_key((string) ($config['gzh_type'] ?? '')); $token = trim((string) ($config['token'] ?? '')); if ($type === 'service') { return $token !== '' && trim((string) ($config['appid'] ?? '')) !== '' && trim((string) ($config['appsecret'] ?? '')) !== ''; } if ($type === 'subscribe') { return $token !== '' && trim((string) ($config['code_qrcode'] ?? '')) !== '' && trim((string) ($config['code_keyword'] ?? '')) !== ''; } return false; } public function access_token(): string { $config = self::settings(); $appid = trim((string) ($config['appid'] ?? '')); $secret = trim((string) ($config['appsecret'] ?? '')); if ($appid === '' || $secret === '') { return ''; } return $this->get_access_token($appid, $secret); } public function rest_qrcode_login(\WP_REST_Request $request) { $limited = $this->rate_limit('qr-create', 12, MINUTE_IN_SECONDS, 2); if ($limited) { return $limited; } if (!self::is_enabled()) { return $this->error_response(__('微信公众号登录未开启或配置不完整', 'jio'), 400); } $context = $this->request_context($request); if (is_wp_error($context)) { return $this->error_response($context->get_error_message(), 403); } $config = self::settings(); $mode = (string) ($config['gzh_type'] ?? ''); if ($context['action'] === 'bind') { $data = $this->request_data($request); $bind_pass = sanitize_text_field((string) ($data['bind_pass'] ?? '')); if (!OAuth::validate_bind_pass($bind_pass, 'weixingzh', $context['user_id'], false)) { return $this->error_response(__('请先完成人机验证后再绑定', 'jio'), 403); } $context['bind_pass'] = $bind_pass; } if ($mode === 'subscribe') { return $this->code_mode_response($config, $context); } $appid = trim((string) ($config['appid'] ?? '')); $secret = trim((string) ($config['appsecret'] ?? '')); $scene = 'jio_' . rtrim(strtr(base64_encode(random_bytes(18)), '+/', '-_'), '='); $transaction = ''; try { $transaction = OAuthTransactionStore::issue(self::SCAN_KIND, [ 'status' => 'pending', 'scene' => $scene, 'openid' => '', 'profile' => [], 'redirect_to' => $context['redirect_to'], 'action' => $context['action'], 'user_id' => $context['user_id'], 'bind_pass' => (string) ($context['bind_pass'] ?? ''), ], self::SCAN_TTL, true); if (!$this->store_scene_mapping($scene, $transaction)) { throw new \RuntimeException('scene_mapping_failed'); } $access_token = $this->get_access_token($appid, $secret); $created = $this->api_post( 'https://api.weixin.qq.com/cgi-bin/qrcode/create?access_token=' . rawurlencode($access_token), [ 'expire_seconds' => self::SCAN_TTL, 'action_name' => 'QR_STR_SCENE', 'action_info' => ['scene' => ['scene_str' => $scene]], ] ); if (empty($created['ticket'])) { throw new \RuntimeException((string) ($created['errmsg'] ?? 'qrcode_create_failed')); } $image = 'https://mp.weixin.qq.com/cgi-bin/showqrcode?ticket=' . rawurlencode((string) $created['ticket']); $html = '' . esc_attr__('微信公众号扫码登录二维码', 'jio') . ''; $html .= '

' . esc_html__('微信扫码登录', 'jio') . '

'; return new \WP_REST_Response([ 'success' => true, 'data' => [ 'mode' => 'scan', 'qr_html' => $html, 'check_url' => '/auth/check-qrcode-login', 'token' => $transaction, 'poll_interval' => 3, ], ], 200); } catch (\Throwable $e) { if ($transaction !== '') { OAuthTransactionStore::delete($transaction); $this->delete_scene_mapping($scene, $transaction); } return $this->error_response(__('创建微信登录二维码失败,请稍后重试', 'jio'), 502); } } public function rest_check_qrcode_login(\WP_REST_Request $request) { $limited = $this->rate_limit('qr-check', 150, 5 * MINUTE_IN_SECONDS, 1); if ($limited) { return $limited; } if (!self::is_enabled()) { return $this->error_response(__('微信公众号登录未开启或配置不完整', 'jio'), 400); } $data = $this->request_data($request); $token = sanitize_text_field((string) ($data['token'] ?? '')); if ($token === '') { return $this->error_response(__('缺少 token', 'jio'), 400); } $row = OAuthTransactionStore::peek($token, self::SCAN_KIND, true); if (!$row) { return new \WP_REST_Response(['success' => true, 'data' => ['logged_in' => false, 'status' => 'expired']], 200); } $payload = is_array($row['payload'] ?? null) ? $row['payload'] : []; $status = sanitize_key((string) ($payload['status'] ?? 'pending')); if ($status !== 'ok') { return new \WP_REST_Response([ 'success' => true, 'data' => ['logged_in' => false, 'status' => $status !== '' ? $status : 'pending', 'poll_interval' => 3], ], 200); } if (!$this->binding_user_matches($payload)) { return $this->error_response(__('绑定会话与当前用户不匹配', 'jio'), 403); } $claimed = OAuthTransactionStore::claim($token, self::SCAN_KIND, true); if (!$claimed) { return new \WP_REST_Response(['success' => true, 'data' => ['logged_in' => false, 'status' => 'expired']], 200); } $payload = is_array($claimed['payload'] ?? null) ? $claimed['payload'] : []; return $this->complete_browser_identity($payload); } public function rest_code_login(\WP_REST_Request $request) { $limited = $this->rate_limit('code', self::CODE_MAX_ATTEMPTS, self::CODE_TTL, 1); if ($limited) { return $limited; } if (!self::is_enabled()) { return $this->error_response(__('微信公众号登录未开启或配置不完整', 'jio'), 400); } $data = $this->request_data($request); $code = strtoupper(sanitize_text_field((string) ($data['code'] ?? ''))); if (preg_match('/^[A-Z0-9]{6,12}$/', $code) !== 1) { return $this->error_response(__('请输入至少 6 位的有效验证码', 'jio'), 400); } $context = $this->request_context($request); if (is_wp_error($context)) { return $this->error_response($context->get_error_message(), 403); } $bind_pass = ''; if ($context['action'] === 'bind') { $bind_pass = sanitize_text_field((string) ($data['bind_pass'] ?? '')); if (!OAuth::validate_bind_pass($bind_pass, 'weixingzh', $context['user_id'], false)) { return $this->error_response(__('请先完成人机验证后再绑定', 'jio'), 403); } } $claimed = $this->claim_code_transaction($code, $context); if (!$claimed) { return $this->error_response(__('验证码无效、已过期或尝试次数过多', 'jio'), 400); } $payload = is_array($claimed['payload'] ?? null) ? $claimed['payload'] : []; if ($context['action'] === 'bind') { $payload['bind_pass'] = $bind_pass; } return $this->complete_browser_identity($payload); } /** * WeChat server URL verification. */ public function maybe_server_verify(): void { if (empty($_GET['jio_weixingzh']) || ($_SERVER['REQUEST_METHOD'] ?? '') !== 'GET' || !isset($_GET['echostr'])) { return; } if (!self::is_enabled() || !$this->verify_webhook_signature()) { status_header(403); exit; } $echo = (string) wp_unslash($_GET['echostr']); if (strlen($echo) > 256 || preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', $echo)) { status_header(400); exit; } header('Content-Type: text/plain; charset=utf-8'); echo $echo; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Protocol challenge response. exit; } /** * Receive plaintext WeChat server messages. */ public function maybe_server_message(): void { if (empty($_GET['jio_weixingzh']) || ($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST' || isset($_GET['echostr'])) { return; } if (!self::is_enabled() || !$this->verify_webhook_signature()) { status_header(403); exit; } $content_length = isset($_SERVER['CONTENT_LENGTH']) ? (int) $_SERVER['CONTENT_LENGTH'] : 0; if ($content_length > self::MAX_WEBHOOK_BODY) { status_header(413); exit; } $raw = file_get_contents('php://input', false, null, 0, self::MAX_WEBHOOK_BODY + 1); if (!is_string($raw) || $raw === '' || strlen($raw) > self::MAX_WEBHOOK_BODY) { status_header($raw === '' ? 400 : 413); exit; } $previous = libxml_use_internal_errors(true); try { $xml = simplexml_load_string($raw, 'SimpleXMLElement', LIBXML_NONET | LIBXML_NOCDATA); } finally { libxml_clear_errors(); libxml_use_internal_errors($previous); } if (!$xml) { status_header(400); exit; } $dedupe_key = $this->claim_webhook_event($xml); if ($dedupe_key === false) { $this->echo_success(); } try { $this->handle_webhook_message($xml); } catch (\Throwable $e) { delete_option($dedupe_key); status_header(500); exit; } } /** * @param array $config * @param array{action:string,user_id:int,redirect_to:string} $context */ private function code_mode_response(array $config, array $context): \WP_REST_Response { $qr = trim((string) ($config['code_qrcode'] ?? '')); $keyword = trim((string) ($config['code_keyword'] ?? __('登录', 'jio'))); $html = ''; $html .= '
'; $html .= '

' . esc_html(sprintf(__('关注公众号后发送「%s」获取验证码', 'jio'), $keyword)) . '

'; $html .= ''; $html .= ''; $html .= ''; if (!empty($context['bind_pass'])) { $html .= ''; } $html .= ''; $html .= ''; $html .= '
'; return new \WP_REST_Response([ 'success' => true, 'data' => ['mode' => 'code', 'qr_html' => $html, 'check_url' => '', 'token' => ''], ], 200); } /** * @return array{action:string,user_id:int,redirect_to:string}|\WP_Error */ private function request_context(\WP_REST_Request $request) { $data = $this->request_data($request); $action = sanitize_key((string) ($data['action'] ?? 'login')) === 'bind' ? 'bind' : 'login'; $current_user_id = get_current_user_id(); $requested_user_id = isset($data['user_id']) ? (int) $data['user_id'] : 0; if ($action === 'bind') { if ($current_user_id <= 0) { return new \WP_Error('weixingzh_bind_login_required', __('请先登录后再绑定微信公众号', 'jio')); } if ($requested_user_id > 0 && $requested_user_id !== $current_user_id) { return new \WP_Error('weixingzh_bind_user_mismatch', __('绑定请求与当前用户不匹配', 'jio')); } } $redirect = OAuthUrlPolicy::validated_redirect((string) ($data['redirect_to'] ?? ''), home_url('/')); return [ 'action' => $action, 'user_id' => $current_user_id, 'redirect_to' => $redirect, ]; } /** * @return array */ private function request_data(\WP_REST_Request $request): array { $params = $request->get_params(); $json = $request->get_json_params(); return array_merge(is_array($params) ? $params : [], is_array($json) ? $json : []); } /** * @param array $payload */ private function binding_user_matches(array $payload): bool { if ((string) ($payload['action'] ?? 'login') !== 'bind') { return true; } $user_id = (int) ($payload['user_id'] ?? 0); return $user_id > 0 && $user_id === get_current_user_id(); } /** * @param array $payload */ private function complete_browser_identity(array $payload): \WP_REST_Response { $openid = trim((string) ($payload['openid'] ?? '')); if ($openid === '') { return $this->error_response(__('微信身份信息无效', 'jio'), 400); } $profile = is_array($payload['profile'] ?? null) ? $payload['profile'] : []; $identity = [ 'provider' => 'weixingzh', 'provider_id' => $openid, 'username' => sanitize_text_field((string) ($profile['nickname'] ?? '')), 'name' => sanitize_text_field((string) ($profile['nickname'] ?? '')), 'email' => '', 'email_verified' => false, 'avatar_url' => esc_url_raw((string) ($profile['headimgurl'] ?? '')), ]; if ((string) ($payload['action'] ?? 'login') === 'bind') { if (!$this->binding_user_matches($payload)) { return $this->error_response(__('绑定会话与当前用户不匹配', 'jio'), 403); } $bind_pass = sanitize_text_field((string) ($payload['bind_pass'] ?? '')); if (!OAuth::validate_bind_pass($bind_pass, 'weixingzh', get_current_user_id(), true)) { return $this->error_response(__('绑定验证已过期,请重新完成人机验证', 'jio'), 403); } $result = OAuthAccountService::bind(get_current_user_id(), 'weixingzh', $identity); if (is_wp_error($result)) { return $this->error_response($result->get_error_message(), 409); } $redirect = function_exists('jio_get_user_center_url') ? jio_get_user_center_url('settings') : home_url('/'); $redirect = add_query_arg(['oauth_action' => 'bind', 'oauth_provider' => 'weixingzh'], $redirect); return new \WP_REST_Response([ 'success' => true, 'data' => ['logged_in' => false, 'bound' => true, 'status' => 'bound', 'redirect' => $redirect], ], 200); } $user = OAuthAccountService::resolve('weixingzh', $identity); if (is_wp_error($user) && $user->get_error_code() === 'oauth_no_match') { try { $redirect = OAuthUrlPolicy::validated_redirect((string) ($payload['redirect_to'] ?? ''), home_url('/')); $obk = OAuthTransactionStore::issue('obk', [ 'provider' => 'weixingzh', 'provider_id' => $openid, 'user_data' => $identity, 'redirect_to' => $redirect, ], 15 * MINUTE_IN_SECONDS, true); $bind_url = function_exists('jio_get_bind_url') ? jio_get_bind_url($obk) : add_query_arg(['tab' => 'bind', 'obk' => $obk], home_url('/sign/')); return new \WP_REST_Response([ 'success' => true, 'data' => [ 'logged_in' => false, 'needs_binding' => true, 'status' => 'needs_binding', 'redirect' => $bind_url, ], ], 200); } catch (\Throwable $e) { return $this->error_response(__('无法创建账号绑定会话,请重试', 'jio'), 500); } } if (!$user instanceof \WP_User) { return $this->error_response(is_wp_error($user) ? $user->get_error_message() : __('微信账号匹配失败', 'jio'), 400); } OAuthAccountService::complete_login($user, true); $redirect = OAuthUrlPolicy::validated_redirect((string) ($payload['redirect_to'] ?? ''), home_url('/')); return new \WP_REST_Response([ 'success' => true, 'data' => [ 'logged_in' => true, 'status' => 'success', 'redirect' => $redirect, 'user' => ['id' => $user->ID, 'display_name' => $user->display_name], ], ], 200); } private function verify_webhook_signature(): bool { $config = self::settings(); $token = trim((string) ($config['token'] ?? '')); $timestamp = isset($_GET['timestamp']) ? (string) wp_unslash($_GET['timestamp']) : ''; $nonce = isset($_GET['nonce']) ? (string) wp_unslash($_GET['nonce']) : ''; $signature = isset($_GET['signature']) ? strtolower((string) wp_unslash($_GET['signature'])) : ''; if ($token === '' || $timestamp === '' || $nonce === '' || preg_match('/^[a-f0-9]{40}$/', $signature) !== 1) { return false; } $parts = [$token, $timestamp, $nonce]; sort($parts, SORT_STRING); return hash_equals(sha1(implode('', $parts)), $signature); } private function handle_webhook_message(\SimpleXMLElement $xml): void { $from = trim((string) ($xml->FromUserName ?? '')); $to = trim((string) ($xml->ToUserName ?? '')); $msg_type = strtolower(trim((string) ($xml->MsgType ?? ''))); $event = strtolower(trim((string) ($xml->Event ?? ''))); $content = trim((string) ($xml->Content ?? '')); if ($from === '' || $to === '') { $this->echo_success(); } $config = self::settings(); if ($msg_type === 'event' && in_array($event, ['subscribe', 'scan'], true)) { $scene = trim((string) ($xml->EventKey ?? '')); if (str_starts_with($scene, 'qrscene_')) { $scene = substr($scene, strlen('qrscene_')); } if ($scene !== '') { $this->mark_scan_by_scene($scene, $from); } $reply = $event === 'scan' ? trim((string) ($config['scan_msg'] ?? '')) : trim((string) ($config['subscribe_msg'] ?? '')); if ($reply !== '') { $this->echo_text_reply($from, $to, $reply); } $this->echo_success(); } $keyword = trim((string) ($config['code_keyword'] ?? '登录')); if ($msg_type === 'text' && $keyword !== '' && $content !== '' && mb_strtolower($content) === mb_strtolower($keyword)) { $code = $this->issue_code($from); $template = trim((string) ($config['code_reply'] ?? '')); if ($template === '') { $template = __('您的验证码是:%s,有效期 %d 分钟。', 'jio'); } $message = str_replace(['%code%', '%time%', '%s', '%d'], [$code, '5', $code, '5'], $template); $this->echo_text_reply($from, $to, $message); } $this->echo_success(); } private function claim_webhook_event(\SimpleXMLElement $xml) { $msg_id = trim((string) ($xml->MsgId ?? '')); if ($msg_id !== '') { $material = 'msg:' . $msg_id; } else { $material = wp_json_encode([ 'from' => (string) ($xml->FromUserName ?? ''), 'to' => (string) ($xml->ToUserName ?? ''), 'time' => (string) ($xml->CreateTime ?? ''), 'type' => (string) ($xml->MsgType ?? ''), 'event' => (string) ($xml->Event ?? ''), 'key' => (string) ($xml->EventKey ?? ''), 'content' => (string) ($xml->Content ?? ''), ]); } $key = self::DEDUPE_OPTION_PREFIX . hash('sha256', (string) $material); $value = ['expires_at' => time() + 10 * MINUTE_IN_SECONDS]; if (add_option($key, $value, '', false)) { return $key; } $current = get_option($key, null); if (is_array($current) && (int) ($current['expires_at'] ?? 0) >= time()) { return false; } $this->compare_delete_option($key, $current); return add_option($key, $value, '', false) ? $key : false; } private function mark_scan_by_scene(string $scene, string $openid): void { if (preg_match('/^[A-Za-z0-9_-]{8,64}$/', $scene) !== 1 || $openid === '') { return; } $scope = 'weixingzh-scene:' . $scene; $lock = OAuthTransactionStore::acquire_lock($scope, 10); if ($lock === false) { return; } try { $key = $this->scene_option_key($scene); $mapping = get_option($key, null); if (!is_array($mapping) || (int) ($mapping['expires_at'] ?? 0) < time()) { delete_option($key); return; } $token = (string) ($mapping['token'] ?? ''); $profile = $this->fetch_profile($openid); $updated = false; for ($attempt = 0; $attempt < 3 && !$updated; $attempt++) { $updated = OAuthTransactionStore::update($token, self::SCAN_KIND, static function (array $row) use ($scene, $openid, $profile) { $payload = is_array($row['payload'] ?? null) ? $row['payload'] : []; if (!hash_equals((string) ($payload['scene'] ?? ''), $scene)) { return false; } $payload['status'] = 'ok'; $payload['openid'] = $openid; $payload['profile'] = $profile; $row['payload'] = $payload; return $row; }, false); } if ($updated) { delete_option($key); } } finally { OAuthTransactionStore::release_lock($scope, (string) $lock); } } private function store_scene_mapping(string $scene, string $token): bool { $scope = 'weixingzh-scene:' . $scene; $lock = OAuthTransactionStore::acquire_lock($scope, 10); if ($lock === false) { return false; } try { $key = $this->scene_option_key($scene); $current = get_option($key, null); if (is_array($current) && (int) ($current['expires_at'] ?? 0) >= time()) { return false; } if ($current !== null) { $this->compare_delete_option($key, $current); } return add_option($key, ['token' => $token, 'expires_at' => time() + self::SCAN_TTL], '', false); } finally { OAuthTransactionStore::release_lock($scope, (string) $lock); } } private function delete_scene_mapping(string $scene, string $token): void { $scope = 'weixingzh-scene:' . $scene; $lock = OAuthTransactionStore::acquire_lock($scope, 10); if ($lock === false) { return; } try { $key = $this->scene_option_key($scene); $current = get_option($key, null); if (is_array($current) && hash_equals((string) ($current['token'] ?? ''), $token)) { $this->compare_delete_option($key, $current); } } finally { OAuthTransactionStore::release_lock($scope, (string) $lock); } } private function scene_option_key(string $scene): string { return self::SCENE_OPTION_PREFIX . hash_hmac('sha256', $scene, wp_salt('auth')); } private function issue_code(string $openid): string { $charset = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; for ($tries = 0; $tries < 10; $tries++) { $code = ''; for ($index = 0; $index < self::CODE_LENGTH; $index++) { $code .= $charset[random_int(0, strlen($charset) - 1)]; } $scope = 'weixingzh-code:' . $code; $lock = OAuthTransactionStore::acquire_lock($scope, 10); if ($lock === false) { continue; } try { $key = $this->code_option_key($code); $current = get_option($key, null); if (is_array($current) && (int) ($current['expires_at'] ?? 0) >= time()) { continue; } if ($current !== null) { $this->compare_delete_option($key, $current); } $transaction = OAuthTransactionStore::issue(self::CODE_KIND, [ 'status' => 'ok', 'scene' => '', 'openid' => $openid, 'profile' => $this->fetch_profile($openid), 'redirect_to' => '', 'action' => 'login', 'user_id' => 0, ], self::CODE_TTL, false); $stored = add_option($key, [ 'token' => $transaction, 'expires_at' => time() + self::CODE_TTL, 'attempts' => 0, ], '', false); if ($stored) { return $code; } OAuthTransactionStore::delete($transaction); } finally { OAuthTransactionStore::release_lock($scope, (string) $lock); } } throw new \RuntimeException('weixingzh_code_issue_failed'); } /** * @param array{action:string,user_id:int,redirect_to:string} $context * @return array|null */ private function claim_code_transaction(string $code, array $context): ?array { $scope = 'weixingzh-code:' . $code; $lock = OAuthTransactionStore::acquire_lock($scope, 10); if ($lock === false) { return null; } try { $key = $this->code_option_key($code); $mapping = get_option($key, null); if (!is_array($mapping) || (int) ($mapping['expires_at'] ?? 0) < time()) { delete_option($key); return null; } $attempts = (int) ($mapping['attempts'] ?? 0) + 1; if ($attempts > self::CODE_MAX_ATTEMPTS) { $this->compare_delete_option($key, $mapping); OAuthTransactionStore::delete((string) ($mapping['token'] ?? '')); return null; } $next = $mapping; $next['attempts'] = $attempts; if (!$this->compare_update_option($key, $mapping, $next)) { return null; } $token = (string) ($mapping['token'] ?? ''); $updated = OAuthTransactionStore::update($token, self::CODE_KIND, static function (array $row) use ($context) { $payload = is_array($row['payload'] ?? null) ? $row['payload'] : []; $payload['action'] = $context['action']; $payload['user_id'] = $context['user_id']; $payload['redirect_to'] = $context['redirect_to']; $row['payload'] = $payload; return $row; }, false); if (!$updated) { return null; } $claimed = OAuthTransactionStore::claim($token, self::CODE_KIND, false); if (!$claimed) { return null; } delete_option($key); return $claimed; } finally { OAuthTransactionStore::release_lock($scope, (string) $lock); } } private function code_option_key(string $code): string { return self::CODE_OPTION_PREFIX . hash_hmac('sha256', $code, wp_salt('auth')); } /** * @return array */ private function fetch_profile(string $openid): array { $config = self::settings(); $appid = trim((string) ($config['appid'] ?? '')); $secret = trim((string) ($config['appsecret'] ?? '')); if ($appid === '' || $secret === '') { return []; } try { $access_token = $this->get_access_token($appid, $secret); $url = add_query_arg([ 'access_token' => $access_token, 'openid' => $openid, 'lang' => 'zh_CN', ], 'https://api.weixin.qq.com/cgi-bin/user/info'); $response = wp_remote_get($url, ['timeout' => 10]); $data = $this->decode_http_json($response); if (!empty($data['errcode'])) { return []; } return [ 'nickname' => sanitize_text_field((string) ($data['nickname'] ?? '')), 'headimgurl' => esc_url_raw((string) ($data['headimgurl'] ?? '')), ]; } catch (\Throwable $e) { return []; } } private function get_access_token(string $appid, string $secret): string { $cached = get_option(self::ACCESS_OPTION, []); if ($this->valid_access_cache($cached, $appid)) { return (string) $cached['access_token']; } $scope = 'weixingzh-access:' . $appid; $lock = OAuthTransactionStore::acquire_lock($scope, 20); if ($lock === false) { for ($attempt = 0; $attempt < 4; $attempt++) { usleep(250000); $cached = get_option(self::ACCESS_OPTION, []); if ($this->valid_access_cache($cached, $appid)) { return (string) $cached['access_token']; } } throw new \RuntimeException('access_token_busy'); } try { $cached = get_option(self::ACCESS_OPTION, []); if ($this->valid_access_cache($cached, $appid)) { return (string) $cached['access_token']; } $data = []; $stable = wp_remote_post('https://api.weixin.qq.com/cgi-bin/stable_token', [ 'timeout' => 10, 'headers' => ['Content-Type' => 'application/json'], 'body' => wp_json_encode([ 'grant_type' => 'client_credential', 'appid' => $appid, 'secret' => $secret, ]), ]); try { $data = $this->decode_http_json($stable); } catch (\Throwable $e) { $data = []; } if (empty($data['access_token'])) { $url = add_query_arg([ 'grant_type' => 'client_credential', 'appid' => $appid, 'secret' => $secret, ], 'https://api.weixin.qq.com/cgi-bin/token'); $data = $this->decode_http_json(wp_remote_get($url, ['timeout' => 10])); } if (empty($data['access_token'])) { throw new \RuntimeException((string) ($data['errmsg'] ?? 'access_token_failed')); } $cache = [ 'appid' => $appid, 'access_token' => (string) $data['access_token'], 'expiration_time' => time() + max(300, (int) ($data['expires_in'] ?? 7200)), ]; update_option(self::ACCESS_OPTION, $cache, false); return $cache['access_token']; } finally { OAuthTransactionStore::release_lock($scope, (string) $lock); } } private function valid_access_cache($cached, string $appid): bool { return is_array($cached) && !empty($cached['access_token']) && isset($cached['appid']) && hash_equals($appid, (string) $cached['appid']) && (int) ($cached['expiration_time'] ?? 0) > time() + 60; } /** * @param array $body * @return array */ private function api_post(string $url, array $body): array { $response = wp_remote_post($url, [ 'timeout' => 10, 'headers' => ['Content-Type' => 'application/json'], 'body' => wp_json_encode($body, JSON_UNESCAPED_UNICODE), ]); $data = $this->decode_http_json($response); if (!empty($data['errcode'])) { throw new \RuntimeException((string) ($data['errmsg'] ?? 'weixin_api_failed')); } return $data; } /** * @return array */ private function decode_http_json($response): array { if (is_wp_error($response)) { throw new \RuntimeException($response->get_error_message()); } $status = (int) wp_remote_retrieve_response_code($response); if ($status < 200 || $status >= 300) { throw new \RuntimeException('weixin_http_' . $status); } $data = json_decode((string) wp_remote_retrieve_body($response), true); if (!is_array($data)) { throw new \RuntimeException('weixin_invalid_json'); } return $data; } private function rate_limit(string $scope, int $max, int $window, int $minimum_interval): ?\WP_REST_Response { $ip = function_exists('jio_get_client_ip') ? trim((string) jio_get_client_ip()) : trim((string) ($_SERVER['REMOTE_ADDR'] ?? 'unknown')); $key = 'jio_weixingzh_rate_' . hash('sha256', $scope . '|' . $ip); $lock_scope = 'weixingzh-rate:' . $key; $lock = OAuthTransactionStore::acquire_lock($lock_scope, 5); if ($lock === false) { return $this->error_response(__('请求过于频繁,请稍后重试', 'jio'), 429); } try { $now = time(); $row = get_transient($key); if (!is_array($row) || (int) ($row['reset_at'] ?? 0) <= $now) { $row = ['count' => 0, 'last_at' => 0, 'reset_at' => $now + $window]; } $last_at = (float) ($row['last_at'] ?? 0); if ((int) ($row['count'] ?? 0) >= $max || microtime(true) - $last_at < $minimum_interval) { return $this->error_response(__('请求过于频繁,请稍后重试', 'jio'), 429); } $row['count'] = (int) $row['count'] + 1; $row['last_at'] = microtime(true); set_transient($key, $row, max(1, (int) $row['reset_at'] - $now)); return null; } finally { OAuthTransactionStore::release_lock($lock_scope, (string) $lock); } } public function maybe_cleanup_options(): void { if (wp_rand(1, 100) !== 1) { return; } global $wpdb; $patterns = [self::SCENE_OPTION_PREFIX, self::CODE_OPTION_PREFIX, self::DEDUPE_OPTION_PREFIX]; foreach ($patterns as $prefix) { $rows = $wpdb->get_results($wpdb->prepare( "SELECT option_name, option_value FROM {$wpdb->options} WHERE option_name LIKE %s LIMIT 100", $wpdb->esc_like($prefix) . '%' ), ARRAY_A); foreach (is_array($rows) ? $rows : [] as $row) { $value = maybe_unserialize($row['option_value'] ?? ''); if (is_array($value) && (int) ($value['expires_at'] ?? 0) < time()) { delete_option((string) $row['option_name']); } } } } private function compare_update_option(string $key, $current, $next): bool { global $wpdb; $updated = $wpdb->query($wpdb->prepare( "UPDATE {$wpdb->options} SET option_value = %s WHERE option_name = %s AND option_value = %s", maybe_serialize($next), $key, maybe_serialize($current) )); if ($updated === 1) { wp_cache_delete($key, 'options'); return true; } return false; } private function compare_delete_option(string $key, $current): bool { global $wpdb; $deleted = $wpdb->query($wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name = %s AND option_value = %s", $key, maybe_serialize($current) )); if ($deleted === 1) { wp_cache_delete($key, 'options'); return true; } return false; } private function error_response(string $message, int $status): \WP_REST_Response { return new \WP_REST_Response(['success' => false, 'message' => $message], $status); } private function echo_text_reply(string $to_user, string $from_user, string $content): void { $to_user = htmlspecialchars($to_user, ENT_XML1 | ENT_QUOTES, 'UTF-8'); $from_user = htmlspecialchars($from_user, ENT_XML1 | ENT_QUOTES, 'UTF-8'); $content = str_replace(']]>', ']]]]>', $content); header('Content-Type: application/xml; charset=utf-8'); echo '' . time() . ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- WeChat XML protocol response. exit; } private function echo_success(): void { header('Content-Type: text/plain; charset=utf-8'); echo 'success'; exit; } }