['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 */ public static function settings(): array { if (class_exists('\AKSRT\Settings\Settings') && method_exists('\AKSRT\Settings\Settings', 'get_weixingzh_settings')) { return (array) \AKSRT\Settings\Settings::get_weixingzh_settings(); } return []; } public static function is_enabled(): bool { $s = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_all() : []; if (!empty($s['oauth_weixingzh_s'])) { return true; } return !empty($s['weixingzh_appid']) && !empty($s['weixingzh_appsecret']); } public function rest_qrcode_login(\WP_REST_Request $request) { if (!self::is_enabled()) { return new \WP_REST_Response(['success' => false, 'message' => __('微信公众号登录未开启', 'aksrt')], 400); } $cfg = self::settings(); $appid = (string) ($cfg['appid'] ?? ''); $secret = (string) ($cfg['appsecret'] ?? ''); $gzh_type = (string) ($cfg['gzh_type'] ?? 'service'); // Subscribe / uncertified: static QR + code form. if ($gzh_type === 'subscribe' || $gzh_type === 'not' || empty($secret)) { $qr = (string) ($cfg['code_qrcode'] ?? ''); $keyword = (string) ($cfg['code_keyword'] ?? __('登录', 'aksrt')); if ($qr === '') { return new \WP_REST_Response(['success' => false, 'message' => __('未配置公众号二维码', 'aksrt')], 400); } $html = ''; $html .= '
'; $html .= '

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

'; $html .= ''; $html .= ''; $html .= '
'; return new \WP_REST_Response([ 'success' => true, 'data' => [ 'mode' => 'code', 'qr_html' => $html, 'check_url' => '', 'token' => '', ], ], 200); } // Service account: temporary QR with scene string (state). try { $token = $this->get_access_token($appid, $secret); $state = 'aksrt_' . wp_generate_password(16, false, false); $scene = substr(md5($state . wp_salt('auth')), 0, 24); $create = $this->api_post( 'https://api.weixin.qq.com/cgi-bin/qrcode/create?access_token=' . rawurlencode($token), [ 'expire_seconds' => 300, 'action_name' => 'QR_STR_SCENE', 'action_info' => ['scene' => ['scene_str' => $scene]], ] ); if (empty($create['ticket'])) { $msg = (string) ($create['errmsg'] ?? __('创建二维码失败', 'aksrt')); return new \WP_REST_Response(['success' => false, 'message' => $msg], 400); } $img = 'https://mp.weixin.qq.com/cgi-bin/showqrcode?ticket=' . rawurlencode((string) $create['ticket']); $this->put_scan_state($state, [ 'status' => 'pending', 'scene' => $scene, 'created' => time(), 'openid' => '', 'user_id' => 0, ]); $html = ''; $html .= '

' . 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 = ''; $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 "{$time}"; exit; } private function issue_code(string $openid): string { $charset = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; $code = ''; for ($i = 0; $i < 4; $i++) { $code .= $charset[random_int(0, strlen($charset) - 1)]; } $data = get_option(self::OPT_CODES, []); if (!is_array($data)) { $data = []; } $now = time(); // prune foreach ($data as $k => $row) { if (!is_array($row) || (int) ($row['time'] ?? 0) + 300 < $now) { unset($data[$k]); } } $data[$code] = ['time' => $now, 'openid' => $openid]; update_option(self::OPT_CODES, $data, false); return $code; } private function consume_code(string $code): ?string { $data = get_option(self::OPT_CODES, []); if (!is_array($data) || empty($data[$code])) { return null; } $row = $data[$code]; unset($data[$code]); update_option(self::OPT_CODES, $data, false); if ((int) ($row['time'] ?? 0) + 300 < time()) { return null; } $openid = (string) ($row['openid'] ?? ''); return $openid !== '' ? $openid : null; } /** * @param array $profile * @return \WP_User|\WP_Error */ private function login_or_create_by_openid(string $openid, array $profile) { $oauth = class_exists(OAuth::class) ? OAuth::get_instance() : null; $user_data = [ 'provider' => 'weixingzh', 'provider_id' => $openid, 'username' => (string) ($profile['nickname'] ?? ''), 'name' => (string) ($profile['nickname'] ?? ''), 'email' => '', 'avatar_url' => (string) ($profile['headimgurl'] ?? ''), ]; // Find by meta. $users = get_users([ 'meta_key' => 'aksrt_weixingzh_id', 'meta_value' => $openid, 'number' => 1, 'fields' => 'all', ]); if (!empty($users[0]) && $users[0] instanceof \WP_User) { $user = $users[0]; wp_set_current_user($user->ID); wp_set_auth_cookie($user->ID, true); do_action('wp_login', $user->user_login, $user); return $user; } $login = 'wx' . substr(md5($openid), 0, 10); $uid = wp_insert_user([ 'user_login' => $login, 'user_pass' => wp_generate_password(24, true, true), 'display_name' => $user_data['name'] !== '' ? $user_data['name'] : $login, 'nickname' => $user_data['name'] !== '' ? $user_data['name'] : $login, 'role' => get_option('default_role', 'subscriber'), ]); if (is_wp_error($uid)) { return $uid; } update_user_meta((int) $uid, 'aksrt_weixingzh_id', $openid); $bindings = get_user_meta((int) $uid, 'aksrt_oauth_bindings', true); $bindings = is_array($bindings) ? $bindings : []; if (!in_array('weixingzh', $bindings, true)) { $bindings[] = 'weixingzh'; update_user_meta((int) $uid, 'aksrt_oauth_bindings', $bindings); } if ($user_data['avatar_url'] !== '') { update_user_meta((int) $uid, 'aksrt_oauth_avatar', esc_url_raw($user_data['avatar_url'])); } $user = get_user_by('id', (int) $uid); if (!$user instanceof \WP_User) { return new \WP_Error('aksrt_wx_user', __('创建用户失败', 'aksrt')); } wp_set_current_user($user->ID); wp_set_auth_cookie($user->ID, true); do_action('wp_login', $user->user_login, $user); return $user; } /** * @param array $row */ private function put_scan_state(string $state, array $row): void { $all = get_option(self::OPT_SCANS, []); if (!is_array($all)) { $all = []; } $now = time(); foreach ($all as $k => $v) { if (!is_array($v) || (int) ($v['created'] ?? 0) + 600 < $now) { unset($all[$k]); } } $all[$state] = $row; update_option(self::OPT_SCANS, $all, false); } /** * @return array|null */ private function get_scan_state(string $state): ?array { $all = get_option(self::OPT_SCANS, []); if (!is_array($all) || empty($all[$state]) || !is_array($all[$state])) { return null; } return $all[$state]; } private function delete_scan_state(string $state): void { $all = get_option(self::OPT_SCANS, []); if (!is_array($all)) { return; } unset($all[$state]); update_option(self::OPT_SCANS, $all, false); } private function mark_scan_by_scene(string $scene, string $openid): void { $all = get_option(self::OPT_SCANS, []); if (!is_array($all)) { return; } foreach ($all as $state => $row) { if (!is_array($row)) { continue; } if ((string) ($row['scene'] ?? '') === $scene) { $user = $this->login_or_create_by_openid($openid, []); if ($user instanceof \WP_User) { // Don't keep session for server push path; store id for poller. wp_logout(); $all[$state]['status'] = 'ok'; $all[$state]['openid'] = $openid; $all[$state]['user_id'] = $user->ID; update_option(self::OPT_SCANS, $all, false); } return; } } } /** * @throws \Exception */ private function get_access_token(string $appid, string $secret): string { $cached = get_option(self::OPT_ACCESS, []); if (is_array($cached) && !empty($cached['access_token']) && (int) ($cached['expiration_time'] ?? 0) > time() + 60) { return (string) $cached['access_token']; } $url = add_query_arg([ 'grant_type' => 'client_credential', 'appid' => $appid, 'secret' => $secret, ], 'https://api.weixin.qq.com/cgi-bin/token'); // Prefer stable_token when available. $res = 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, ]), ]); if (is_wp_error($res)) { $res = wp_remote_get($url, ['timeout' => 10]); } if (is_wp_error($res)) { throw new \Exception($res->get_error_message()); } $data = json_decode((string) wp_remote_retrieve_body($res), true); if (empty($data['access_token'])) { throw new \Exception((string) ($data['errmsg'] ?? 'access_token failed')); } $data['expiration_time'] = time() + (int) ($data['expires_in'] ?? 7200); update_option(self::OPT_ACCESS, $data, false); return (string) $data['access_token']; } /** * @param array $body * @return array */ private function api_post(string $url, array $body): array { $res = wp_remote_post($url, [ 'timeout' => 10, 'headers' => ['Content-Type' => 'application/json'], 'body' => wp_json_encode($body, JSON_UNESCAPED_UNICODE), ]); if (is_wp_error($res)) { return ['errcode' => 1, 'errmsg' => $res->get_error_message()]; } $data = json_decode((string) wp_remote_retrieve_body($res), true); return is_array($data) ? $data : []; } }