Files
wordpress-theme-aksrt/src/Auth/WeixinGzh.php
T

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;
}
}