diff --git a/assets/js/modal-auth.js b/assets/js/modal-auth.js index f1b7b7a7..46f67739 100644 --- a/assets/js/modal-auth.js +++ b/assets/js/modal-auth.js @@ -419,6 +419,37 @@ // ── QR Code Login (§10.7) ── + + function bindWeixingzhCodeForm(container) { + if (!container) return; + var form = container.querySelector('[data-aksrt-weixingzh-code]'); + if (!form || form._bound) return; + form._bound = true; + form.addEventListener('submit', function (e) { + e.preventDefault(); + var input = form.querySelector('[name="code"]'); + var code = input ? String(input.value || '').trim() : ''; + if (!code) return; + var btn = form.querySelector('button[type="submit"]'); + if (btn) btn.disabled = true; + request('/auth/weixingzh-code', { code: code }) + .then(function (result) { + var data = (result && result.data) || result || {}; + if ((result && result.ok) || (result && result.success) || data.logged_in) { + onAuthSuccess(data.data || data); + return; + } + throw new Error((result && (result.message || result.msg)) || '验证码错误'); + }) + .catch(function (err) { + alert(err.message || '登录失败'); + }) + .finally(function () { + if (btn) btn.disabled = false; + }); + }); + } + function initQrLogin() { var qrPanel = modal.querySelector('[data-panel="qrcode"]'); if (!qrPanel) return; @@ -442,9 +473,16 @@ request('/auth/qrcode-login', {}) .then(function (result) { - if (result.ok && result.data && result.data.qr_html) { - container.innerHTML = result.data.qr_html; - startQrPolling(result.data.check_url || '/auth/check-qrcode-login', result.data.token || ''); + var payload = (result && result.data) || result || {}; + var qrHtml = payload.qr_html || (payload.data && payload.data.qr_html) || ''; + var checkUrl = payload.check_url || (payload.data && payload.data.check_url) || '/auth/check-qrcode-login'; + var token = payload.token || (payload.data && payload.data.token) || ''; + if (qrHtml) { + container.innerHTML = qrHtml; + bindWeixingzhCodeForm(container); + if (checkUrl && token) { + startQrPolling(checkUrl, token); + } } else { container.innerHTML = '
扫码登录暂不可用
'; } @@ -459,9 +497,13 @@ qrLoginTimer = setInterval(function () { request(checkUrl, { token: token }) .then(function (result) { - if (result.ok && result.data && result.data.logged_in) { - stopQrPolling(); - onAuthSuccess(result.data); + var data = (result && result.data) || result || {}; + var body = data.data || data; + if ((result && result.ok) || data.success || body.logged_in) { + if (body.logged_in || (data.data && data.data.logged_in)) { + stopQrPolling(); + onAuthSuccess(body.data || body); + } } }) .catch(function () { /* keep polling */ }); diff --git a/functions.php b/functions.php index 51e249b3..5767db5d 100644 --- a/functions.php +++ b/functions.php @@ -137,6 +137,9 @@ if (get_option('aksrt_flushed_version') !== AKSRT_VERSION) { \AKSRT\Core\Scripts::get_instance()->init(); \AKSRT\Admin\Admin::get_instance()->init(); \AKSRT\Auth\OAuth::get_instance()->init(); +if (class_exists('\AKSRT\Auth\WeixinGzh')) { + \AKSRT\Auth\WeixinGzh::get_instance()->init(); +} // 初始化代理登录(根据配置模式选择性加载) $aksrt_proxy_mode = \AKSRT\Settings\Settings::oauth_proxy_mode(); diff --git a/src/AKSRT/Auth/WeixinGzh.php b/src/AKSRT/Auth/WeixinGzh.php new file mode 100644 index 00000000..288093f2 --- /dev/null +++ b/src/AKSRT/Auth/WeixinGzh.php @@ -0,0 +1,540 @@ + ['GET', 'POST'], + 'callback' => [$this, 'rest_qrcode_login'], + 'permission_callback' => '__return_true', + ]); + register_rest_route('aksrt/v1', '/auth/check-qrcode-login', [ + 'methods' => ['GET', 'POST'], + 'callback' => [$this, 'rest_check_qrcode_login'], + 'permission_callback' => '__return_true', + ]); + register_rest_route('aksrt/v1', '/auth/weixingzh-code', [ + 'methods' => 'POST', + 'callback' => [$this, 'rest_code_login'], + 'permission_callback' => '__return_true', + ]); + } + + /** + * @return array' . esc_html__('微信扫码登录', 'aksrt') . '
'; + return new \WP_REST_Response([ + 'success' => true, + 'data' => [ + 'mode' => 'scan', + 'qr_html' => $html, + 'check_url' => '/auth/check-qrcode-login', + 'token' => $state, + ], + ], 200); + } catch (\Throwable $e) { + // Fallback to static QR form if API fails. + $qr = (string) ($cfg['code_qrcode'] ?? ''); + if ($qr === '') { + return new \WP_REST_Response(['success' => false, 'message' => $e->getMessage()], 400); + } + $html = '' . esc_html__('请使用公众号二维码 + 验证码登录', 'aksrt') . '
'; + return new \WP_REST_Response([ + 'success' => true, + 'data' => ['mode' => 'code', 'qr_html' => $html, 'check_url' => '', 'token' => ''], + ], 200); + } + } + + public function rest_check_qrcode_login(\WP_REST_Request $request) { + $params = $request->get_json_params(); + if (!is_array($params)) { + $params = $request->get_params(); + } + $token = sanitize_text_field((string) ($params['token'] ?? $request->get_param('token') ?? '')); + if ($token === '') { + return new \WP_REST_Response(['success' => false, 'message' => __('缺少 token', 'aksrt')], 400); + } + $row = $this->get_scan_state($token); + if (!$row) { + return new \WP_REST_Response(['success' => true, 'data' => ['logged_in' => false, 'status' => 'expired']], 200); + } + if (($row['status'] ?? '') === 'ok' && !empty($row['user_id'])) { + $user = get_user_by('id', (int) $row['user_id']); + if ($user instanceof \WP_User) { + wp_set_current_user($user->ID); + wp_set_auth_cookie($user->ID, true); + do_action('wp_login', $user->user_login, $user); + $this->delete_scan_state($token); + return new \WP_REST_Response([ + 'success' => true, + 'data' => [ + 'logged_in' => true, + 'redirect' => function_exists('aksrt_get_user_center_url') ? aksrt_get_user_center_url() : home_url('/'), + 'user' => [ + 'id' => $user->ID, + 'display_name' => $user->display_name, + ], + ], + ], 200); + } + } + return new \WP_REST_Response([ + 'success' => true, + 'data' => ['logged_in' => false, 'status' => (string) ($row['status'] ?? 'pending')], + ], 200); + } + + public function rest_code_login(\WP_REST_Request $request) { + $params = $request->get_json_params(); + if (!is_array($params)) { + $params = $request->get_params(); + } + $code = strtoupper(sanitize_text_field((string) ($params['code'] ?? ''))); + if ($code === '') { + return new \WP_REST_Response(['success' => false, 'message' => __('请输入验证码', 'aksrt')], 400); + } + $openid = $this->consume_code($code); + if ($openid === null) { + return new \WP_REST_Response(['success' => false, 'message' => __('验证码无效或已过期', 'aksrt')], 400); + } + $user = $this->login_or_create_by_openid($openid, []); + if (is_wp_error($user)) { + return new \WP_REST_Response(['success' => false, 'message' => $user->get_error_message()], 400); + } + return new \WP_REST_Response([ + 'success' => true, + 'data' => [ + 'logged_in' => true, + 'redirect' => function_exists('aksrt_get_user_center_url') ? aksrt_get_user_center_url() : home_url('/'), + 'user' => [ + 'id' => $user->ID, + 'display_name' => $user->display_name, + ], + ], + ], 200); + } + + /** + * WeChat server URL verification: /?aksrt_weixingzh=1&signature&echostr + */ + public function maybe_server_verify(): void { + if (empty($_GET['aksrt_weixingzh']) || empty($_GET['echostr']) || empty($_GET['signature'])) { + return; + } + $cfg = self::settings(); + if (!empty($cfg['token_nocheck'])) { + echo (string) wp_unslash($_GET['echostr']); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + exit; + } + $token = (string) ($cfg['token'] ?? ''); + $tmp = [(string) $token, (string) ($_GET['timestamp'] ?? ''), (string) ($_GET['nonce'] ?? '')]; + sort($tmp, SORT_STRING); + if (sha1(implode($tmp)) === (string) $_GET['signature']) { + echo (string) wp_unslash($_GET['echostr']); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + exit; + } + status_header(403); + exit; + } + + /** + * Receive WeChat server push (XML) on same endpoint. + */ + public function maybe_server_message(): void { + if (empty($_GET['aksrt_weixingzh']) || empty($_GET['signature']) || !empty($_GET['echostr'])) { + return; + } + if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') { + return; + } + $raw = file_get_contents('php://input'); + if (!is_string($raw) || $raw === '') { + exit; + } + $xml = @simplexml_load_string($raw, 'SimpleXMLElement', LIBXML_NOCDATA); + if (!$xml) { + exit; + } + $from = (string) ($xml->FromUserName ?? ''); + $to = (string) ($xml->ToUserName ?? ''); + $msg_type = (string) ($xml->MsgType ?? ''); + $event = strtolower((string) ($xml->Event ?? '')); + $content = trim((string) ($xml->Content ?? '')); + $cfg = self::settings(); + $keyword = (string) ($cfg['code_keyword'] ?? '登录'); + + // Scene scan login (service QR). + if ($msg_type === 'event' && in_array($event, ['subscribe', 'scan'], true)) { + $scene = (string) ($xml->EventKey ?? ''); + $scene = ltrim($scene, 'qrscene_'); + if ($scene !== '') { + $this->mark_scan_by_scene($scene, $from); + } + $reply = (string) ($cfg['subscribe_msg'] ?? $cfg['scan_msg'] ?? ''); + if ($reply !== '') { + $this->echo_text_reply($from, $to, $reply); + } + echo 'success'; + exit; + } + + // Keyword → verification code. + if ($msg_type === 'text' && $content !== '' && mb_strtolower($content) === mb_strtolower($keyword)) { + $code = $this->issue_code($from); + $tpl = (string) ($cfg['code_reply'] ?? __('您的验证码是:%s,有效期 %d 分钟。', 'aksrt')); + // Support %s/%d and %code%/%time%. + $msg = $tpl; + if (str_contains($msg, '%code%')) { + $msg = str_replace('%code%', $code, $msg); + } + if (str_contains($msg, '%time%')) { + $msg = str_replace('%time%', '5', $msg); + } + if (substr_count($msg, '%') >= 2 && str_contains($msg, '%s')) { + $msg = sprintf($msg, $code, 5); + } elseif (str_contains($msg, '%s')) { + $msg = sprintf($msg, $code); + } + $this->echo_text_reply($from, $to, $msg); + exit; + } + + // Optional auto replies. + if ($msg_type === 'text' && !empty($cfg['auto_reply_text'])) { + $this->echo_text_reply($from, $to, (string) $cfg['auto_reply_text']); + exit; + } + echo 'success'; + exit; + } + + private function echo_text_reply(string $to_user, string $from_user, string $content): void { + $time = time(); + $content = htmlspecialchars($content, ENT_XML1 | ENT_QUOTES, 'UTF-8'); + echo "