963 lines
34 KiB
PHP
963 lines
34 KiB
PHP
<?php
|
|
/**
|
|
* WeChat Official Account QR and verification-code authentication.
|
|
*
|
|
* @package Jio
|
|
*/
|
|
|
|
namespace Jio\Auth;
|
|
|
|
use Jio\Settings\Settings;
|
|
|
|
defined('ABSPATH') || exit;
|
|
|
|
final class WeixinGzh {
|
|
|
|
private const ACCESS_OPTION = 'jio_weixingzh_access_token';
|
|
private const SCENE_OPTION_PREFIX = 'jio_weixingzh_scene_';
|
|
private const CODE_OPTION_PREFIX = 'jio_weixingzh_code_';
|
|
private const DEDUPE_OPTION_PREFIX = 'jio_weixingzh_event_';
|
|
private const SCAN_KIND = 'weixingzh_scan';
|
|
private const CODE_KIND = 'weixingzh_code';
|
|
private const SCAN_TTL = 5 * MINUTE_IN_SECONDS;
|
|
private const CODE_TTL = 5 * MINUTE_IN_SECONDS;
|
|
private const CODE_LENGTH = 8;
|
|
private const CODE_MAX_ATTEMPTS = 5;
|
|
private const MAX_WEBHOOK_BODY = 131072;
|
|
|
|
private static $instance = null;
|
|
|
|
public static function get_instance(): self {
|
|
if (null === self::$instance) {
|
|
self::$instance = new self();
|
|
}
|
|
return self::$instance;
|
|
}
|
|
|
|
public function init(): void {
|
|
add_action('rest_api_init', [$this, 'register_routes']);
|
|
add_action('template_redirect', [$this, 'maybe_server_verify'], 1);
|
|
add_action('template_redirect', [$this, 'maybe_server_message'], 2);
|
|
add_action('init', [$this, 'maybe_cleanup_options'], 30);
|
|
}
|
|
|
|
public function register_routes(): void {
|
|
register_rest_route('jio/v1', '/auth/qrcode-login', [
|
|
'methods' => ['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<string,mixed>
|
|
*/
|
|
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 = '<img class="signin-qrcode-img" src="' . esc_url($image) . '" alt="' . esc_attr__('微信公众号扫码登录二维码', 'jio') . '" style="width:160px;height:160px;" />';
|
|
$html .= '<p class="text-center mt10 em09 muted-2-color"><i class="fa fab fa-weixin c-green"></i> ' . esc_html__('微信扫码登录', 'jio') . '</p>';
|
|
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<string,mixed> $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 = '<img class="signin-qrcode-img" src="' . esc_url($qr) . '" alt="' . esc_attr__('微信公众号验证码登录二维码', 'jio') . '" style="width:160px;height:160px;object-fit:contain;" />';
|
|
$html .= '<form class="jio-weixingzh-code-form" data-jio-weixingzh-code>';
|
|
$html .= '<p class="muted-2-color em09">' . esc_html(sprintf(__('关注公众号后发送「%s」获取验证码', 'jio'), $keyword)) . '</p>';
|
|
$html .= '<input type="hidden" name="action" value="' . esc_attr($context['action']) . '" />';
|
|
$html .= '<input type="hidden" name="user_id" value="' . esc_attr((string) $context['user_id']) . '" />';
|
|
$html .= '<input type="hidden" name="redirect_to" value="' . esc_attr($context['redirect_to']) . '" />';
|
|
if (!empty($context['bind_pass'])) {
|
|
$html .= '<input type="hidden" name="bind_pass" value="' . esc_attr((string) $context['bind_pass']) . '" />';
|
|
}
|
|
$html .= '<input type="text" name="code" class="form-control" minlength="6" maxlength="12" autocomplete="one-time-code" placeholder="' . esc_attr__('请输入验证码', 'jio') . '" required />';
|
|
$html .= '<button type="submit" class="but c-blue btn-block mt10">' . esc_html__('登录', 'jio') . '</button>';
|
|
$html .= '</form>';
|
|
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<string,mixed>
|
|
*/
|
|
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<string,mixed> $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<string,mixed> $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<string,mixed>|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<string,mixed>
|
|
*/
|
|
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<string,mixed> $body
|
|
* @return array<string,mixed>
|
|
*/
|
|
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<string,mixed>
|
|
*/
|
|
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(']]>', ']]]]><![CDATA[>', $content);
|
|
header('Content-Type: application/xml; charset=utf-8');
|
|
echo '<xml><ToUserName><![CDATA[' . $to_user . ']]></ToUserName><FromUserName><![CDATA[' . $from_user . ']]></FromUserName><CreateTime>' . time() . '</CreateTime><MsgType><![CDATA[text]]></MsgType><Content><![CDATA[' . $content . ']]></Content></xml>'; // 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;
|
|
}
|
|
}
|