Files
wordpress-theme-aksrt/src/Auth/WeixinGzh.php
T
kate522 8c5b1555c9 refactor: flatten src/AKSRT → src (drop theme-identifier dir)
Move all domain folders from src/AKSRT/{Domain} to src/{Domain}; remap
Composer PSR-4 "AKSRT\\": "src/"; update audit script + README + ADR
paths; remove dead tools/build-psr4-map.php (scanned deleted inc/classes).
2026-08-02 22:06:06 +08:00

541 lines
18 KiB
PHP

<?php
/**
* WeChat Official Account (weixingzh) QR / code login helpers.
*
* Complements OAuth snsapi_userinfo web auth with:
* - static QR + keyword verification code (subscribe mode)
* - temporary scene QR + poll state (service mode, best-effort)
*
* @package AKSRT
*/
namespace AKSRT\Auth;
defined('ABSPATH') || exit;
class WeixinGzh {
public const OPT_ACCESS = 'aksrt_weixingzh_access_token';
public const OPT_CODES = 'aksrt_weixingzh_login_codes';
public const OPT_SCANS = 'aksrt_weixingzh_scan_states';
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);
}
public function register_routes(): void {
register_rest_route('aksrt/v1', '/auth/qrcode-login', [
'methods' => ['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<string,mixed>
*/
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 = '<img class="signin-qrcode-img" src="' . esc_url($qr) . '" alt="" style="width:160px;height:160px;object-fit:contain;" />';
$html .= '<form class="aksrt-weixingzh-code-form" data-aksrt-weixingzh-code>';
$html .= '<p class="muted-2-color em09">' . esc_html(sprintf(__('关注公众号后发送「%s」获取验证码', 'aksrt'), $keyword)) . '</p>';
$html .= '<input type="text" name="code" class="form-control" maxlength="8" placeholder="' . esc_attr__('请输入验证码', 'aksrt') . '" required />';
$html .= '<button type="submit" class="but c-blue btn-block mt10">' . esc_html__('登录', 'aksrt') . '</button>';
$html .= '</form>';
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 = '<img class="signin-qrcode-img" src="' . esc_url($img) . '" alt="" style="width:160px;height:160px;" />';
$html .= '<p class="text-center mt10 em09 muted-2-color"><i class="fab fa-weixin c-green"></i> ' . esc_html__('微信扫码登录', 'aksrt') . '</p>';
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 = '<img class="signin-qrcode-img" src="' . esc_url($qr) . '" alt="" style="width:160px;height:160px;" />';
$html .= '<p class="muted-2-color em09">' . esc_html__('请使用公众号二维码 + 验证码登录', 'aksrt') . '</p>';
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 "<xml><ToUserName><![CDATA[{$to_user}]]></ToUserName><FromUserName><![CDATA[{$from_user}]]></FromUserName><CreateTime>{$time}</CreateTime><MsgType><![CDATA[text]]></MsgType><Content><![CDATA[{$content}]]></Content></xml>";
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<string,mixed> $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<string,mixed> $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<string,mixed>|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<string,mixed> $body
* @return array<string,mixed>
*/
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 : [];
}
}